1.0.34
This commit is contained in:
56 files changed
+9101
-117
No files matched your search
@@ -0,0 +1,151 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getTemplateSources, type TemplateSource } from '$lib/server/templates';
|
||||
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
type: 'container' | 'stack';
|
||||
title: string;
|
||||
description: string;
|
||||
logo: string;
|
||||
categories: string[];
|
||||
source: string;
|
||||
image?: string;
|
||||
ports?: string[];
|
||||
volumes?: { bind: string; container: string }[];
|
||||
env?: { name: string; label: string; default?: string }[];
|
||||
restartPolicy?: string;
|
||||
repository?: { url: string; stackfile: string };
|
||||
stars?: number;
|
||||
pulls?: number;
|
||||
network?: string;
|
||||
}
|
||||
|
||||
// Server-side cache: url → { data, fetchedAt }
|
||||
const cache = new Map<string, { data: TemplateItem[]; fetchedAt: number }>();
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
function hashId(source: string, title: string): string {
|
||||
let hash = 0;
|
||||
const str = `${source}:${title}`;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function normalizePortainerTemplate(entry: any, sourceName: string): TemplateItem | null {
|
||||
if (!entry.title && !entry.name) return null;
|
||||
// Skip Swarm templates (type 2)
|
||||
if (entry.type === 2) return null;
|
||||
|
||||
const title = entry.title || entry.name || '';
|
||||
const template: TemplateItem = {
|
||||
id: hashId(sourceName, title),
|
||||
type: entry.type === 3 ? 'stack' : 'container',
|
||||
title,
|
||||
description: entry.description || '',
|
||||
logo: entry.logo || '',
|
||||
categories: Array.isArray(entry.categories) ? entry.categories : [],
|
||||
source: sourceName,
|
||||
};
|
||||
|
||||
if (entry.type === 3 && entry.repository) {
|
||||
template.repository = {
|
||||
url: entry.repository.url,
|
||||
stackfile: entry.repository.stackfile
|
||||
};
|
||||
} else {
|
||||
template.image = entry.image || '';
|
||||
template.ports = Array.isArray(entry.ports) ? entry.ports : [];
|
||||
template.volumes = Array.isArray(entry.volumes) ? entry.volumes : [];
|
||||
template.env = Array.isArray(entry.env) ? entry.env.map((e: any) => ({
|
||||
name: e.name || '',
|
||||
label: e.label || e.name || '',
|
||||
default: e.default ?? e.set ?? ''
|
||||
})) : [];
|
||||
template.restartPolicy = entry.restart_policy || 'unless-stopped';
|
||||
if (entry.network) template.network = entry.network;
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
function normalizeLinuxServerTemplate(entry: any): TemplateItem | null {
|
||||
if (!entry.name || entry.deprecated) return null;
|
||||
|
||||
return {
|
||||
id: hashId('LinuxServer.io', entry.name),
|
||||
type: 'container',
|
||||
title: entry.name,
|
||||
description: entry.description || '',
|
||||
logo: entry.project_logo || '',
|
||||
categories: entry.category ? entry.category.split(',').map((c: string) => c.trim()).filter(Boolean) : [],
|
||||
source: 'LinuxServer.io',
|
||||
image: `lscr.io/linuxserver/${entry.name}:latest`,
|
||||
env: [
|
||||
{ name: 'PUID', label: 'User ID', default: '1000' },
|
||||
{ name: 'PGID', label: 'Group ID', default: '1000' },
|
||||
{ name: 'TZ', label: 'Timezone', default: 'Etc/UTC' },
|
||||
],
|
||||
restartPolicy: 'unless-stopped',
|
||||
stars: entry.stars,
|
||||
pulls: entry.monthly_pulls,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSource(source: TemplateSource): Promise<TemplateItem[]> {
|
||||
const cached = cache.get(source.url);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(source.url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[Templates] Failed to fetch ${source.name}: ${response.status}`);
|
||||
return cached?.data || [];
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
let templates: TemplateItem[];
|
||||
|
||||
if (source.id === 'linuxserver' || source.url.includes('fleet.linuxserver.io')) {
|
||||
// LinuxServer.io fleet API returns an array of images
|
||||
const images = Array.isArray(raw) ? raw : (raw.images || []);
|
||||
templates = images.map(normalizeLinuxServerTemplate).filter(Boolean) as TemplateItem[];
|
||||
} else {
|
||||
// Portainer-format templates
|
||||
const entries = Array.isArray(raw) ? raw : (raw.templates || []);
|
||||
templates = entries.map((e: any) => normalizePortainerTemplate(e, source.name)).filter(Boolean) as TemplateItem[];
|
||||
}
|
||||
|
||||
cache.set(source.url, { data: templates, fetchedAt: Date.now() });
|
||||
return templates;
|
||||
} catch (error) {
|
||||
console.error(`[Templates] Error fetching ${source.name}:`, error instanceof Error ? error.message : error);
|
||||
return cached?.data || [];
|
||||
}
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('templates', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const sources = await getTemplateSources();
|
||||
const enabledSources = sources.filter(s => s.enabled);
|
||||
|
||||
const results = await Promise.all(enabledSources.map(fetchSource));
|
||||
const templates = results.flat();
|
||||
|
||||
return json(templates);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { TemplateItem } from '../+server';
|
||||
|
||||
function generateContainerCompose(template: TemplateItem): string {
|
||||
const name = template.title.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
||||
const lines: string[] = ['services:', ` ${name}:`];
|
||||
|
||||
if (template.image) {
|
||||
lines.push(` image: ${template.image}`);
|
||||
}
|
||||
|
||||
if (template.restartPolicy) {
|
||||
lines.push(` restart: ${template.restartPolicy}`);
|
||||
}
|
||||
|
||||
if (template.network) {
|
||||
lines.push(` network_mode: ${template.network}`);
|
||||
}
|
||||
|
||||
if (template.ports && template.ports.length > 0 && !template.network) {
|
||||
lines.push(' ports:');
|
||||
for (const port of template.ports) {
|
||||
lines.push(` - "${port}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (template.volumes && template.volumes.length > 0) {
|
||||
lines.push(' volumes:');
|
||||
for (const vol of template.volumes) {
|
||||
lines.push(` - ${vol.bind}:${vol.container}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (template.env && template.env.length > 0) {
|
||||
lines.push(' environment:');
|
||||
for (const env of template.env) {
|
||||
const value = env.default || '';
|
||||
lines.push(` - ${env.name}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
async function fetchStackCompose(repository: { url: string; stackfile: string }): Promise<string> {
|
||||
// Convert GitHub URL to raw content URL
|
||||
let rawUrl = '';
|
||||
const githubMatch = repository.url.match(/github\.com\/([^/]+\/[^/]+)/);
|
||||
|
||||
if (githubMatch) {
|
||||
const repo = githubMatch[1].replace(/\.git$/, '');
|
||||
// Try main branch first, then master
|
||||
for (const branch of ['main', 'master']) {
|
||||
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${repository.stackfile}`;
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
||||
if (response.ok) {
|
||||
return await response.text();
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try the URL directly as fallback
|
||||
if (!rawUrl) {
|
||||
rawUrl = repository.url.endsWith('/')
|
||||
? `${repository.url}${repository.stackfile}`
|
||||
: `${repository.url}/${repository.stackfile}`;
|
||||
}
|
||||
|
||||
const response = await fetch(rawUrl, { signal: AbortSignal.timeout(10000) });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch compose file: ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('templates', 'deploy')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { template } = await request.json() as { template: TemplateItem };
|
||||
|
||||
if (!template) {
|
||||
return json({ error: 'Template is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let compose: string;
|
||||
|
||||
if (template.type === 'stack' && template.repository) {
|
||||
compose = await fetchStackCompose(template.repository);
|
||||
} else {
|
||||
compose = generateContainerCompose(template);
|
||||
}
|
||||
|
||||
return json({ compose });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to generate compose';
|
||||
return json({ error: message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getTemplateSources, updateTemplateSource, addTemplateSource, deleteTemplateSource } from '$lib/server/templates';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('templates', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const sources = await getTemplateSources();
|
||||
return json(sources);
|
||||
};
|
||||
|
||||
// Toggle enabled or update a source
|
||||
export const PUT: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('templates', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id, enabled, name, url } = await request.json();
|
||||
if (!id) return json({ error: 'Missing id' }, { status: 400 });
|
||||
|
||||
const updates: any = {};
|
||||
if (enabled !== undefined) updates.enabled = enabled;
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (url !== undefined) updates.url = url;
|
||||
|
||||
await updateTemplateSource(id, updates);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
// Add a custom source
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('templates', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name, url } = await request.json();
|
||||
if (!name?.trim() || !url?.trim()) {
|
||||
return json({ error: 'Name and URL are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const source = await addTemplateSource({ name: name.trim(), url: url.trim() });
|
||||
return json(source);
|
||||
};
|
||||
|
||||
// Delete a custom source
|
||||
export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('templates', 'manage')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = url.searchParams.get('id');
|
||||
if (!id) return json({ error: 'Missing id' }, { status: 400 });
|
||||
|
||||
await deleteTemplateSource(parseInt(id));
|
||||
return json({ ok: true });
|
||||
};
|
||||
Reference in new issue
Block a user