1.0.34
This commit is contained in:
56 files changed
+9101
-117
No files matched your search
@@ -13,10 +13,11 @@ import {
|
||||
} from '$lib/server/auth';
|
||||
import { getUser, getUserByUsername } from '$lib/server/db';
|
||||
import { auditAuth } from '$lib/server/audit';
|
||||
import { getClientIp } from '$lib/server/client-ip';
|
||||
|
||||
// POST /api/auth/login - Authenticate user
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies, getClientAddress } = event;
|
||||
const { request, cookies } = event;
|
||||
// Check if auth is enabled
|
||||
if (!(await isAuthEnabled())) {
|
||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
@@ -29,10 +30,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Rate limiting by IP and username
|
||||
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| request.headers.get('x-real-ip')
|
||||
|| getClientAddress();
|
||||
// Rate-limit key derived from socket IP + username. See client-ip.ts
|
||||
// for how XFF is handled (opt-in via TRUST_FORWARDED_HEADERS).
|
||||
const clientIp = getClientIp(event);
|
||||
const rateLimitKey = `${clientIp}:${username}`;
|
||||
|
||||
const { limited, retryAfter } = isRateLimited(rateLimitKey);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { destroySession } from '$lib/server/auth';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditAuth } from '$lib/server/audit';
|
||||
import { getClientIp } from '$lib/server/client-ip';
|
||||
|
||||
// POST /api/auth/logout - End session
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -11,9 +12,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Get current user before destroying session for audit log
|
||||
const auth = await authorize(cookies);
|
||||
const username = auth.user?.username || 'unknown';
|
||||
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| event.request.headers.get('x-real-ip')
|
||||
|| event.getClientAddress();
|
||||
const clientIp = getClientIp(event);
|
||||
|
||||
await destroySession(cookies);
|
||||
console.log(`[Auth] Logout: user=${username} ip=${clientIp}`);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
|
||||
import { auditAuth } from '$lib/server/audit';
|
||||
import { getClientIp } from '$lib/server/client-ip';
|
||||
|
||||
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
@@ -17,10 +18,8 @@ export const GET: RequestHandler = async (event) => {
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
// Extract client IP for logging
|
||||
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| event.request.headers.get('x-real-ip')
|
||||
|| event.getClientAddress();
|
||||
// Extract client IP for logging.
|
||||
const clientIp = getClientIp(event);
|
||||
|
||||
// Handle error from IdP
|
||||
if (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { gzipSync } from 'node:zlib';
|
||||
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
@@ -33,11 +34,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
// Get format from query parameter (defaults to tar)
|
||||
const format = url.searchParams.get('format') || 'tar';
|
||||
|
||||
// Get stat info to determine filename
|
||||
// Get stat info to determine filename and whether the path is a directory.
|
||||
// Directories with format=raw fall back to tar (raw only makes sense for files).
|
||||
let filename: string;
|
||||
let isDir = false;
|
||||
try {
|
||||
const stat = await statContainerPath(params.id, path, envIdNum);
|
||||
filename = stat.name || path.split('/').pop() || 'download';
|
||||
isDir = stat.isDir === true;
|
||||
} catch {
|
||||
filename = path.split('/').pop() || 'download';
|
||||
}
|
||||
@@ -54,7 +58,13 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let contentType = 'application/x-tar';
|
||||
let extension = '.tar';
|
||||
|
||||
if (format === 'tar.gz') {
|
||||
if (format === 'raw' && !isDir) {
|
||||
// Strip the tar wrapper and emit raw file bytes (#1180).
|
||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||
body = extractFirstFileFromTar(tarData);
|
||||
contentType = 'application/octet-stream';
|
||||
extension = '';
|
||||
} else if (format === 'tar.gz') {
|
||||
// Compress with gzip
|
||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||
body = gzipSync(tarData);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
|
||||
import { getEnvironment, updateEnvironment } from '$lib/server/db';
|
||||
import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
|
||||
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { daemonIsPodman } from '$lib/server/scanner-socket-detect';
|
||||
|
||||
export const POST: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
@@ -35,14 +36,18 @@ export const POST: RequestHandler = async ({ params }) => {
|
||||
// Agent is connected - try to get Docker info with shorter timeout
|
||||
console.log(`[Test] Edge environment ${id} (${env.name}) - agent connected, testing Docker...`);
|
||||
try {
|
||||
const info = await getDockerInfo(env.id) as any;
|
||||
const [info, isPodman] = await Promise.all([
|
||||
getDockerInfo(env.id) as Promise<any>,
|
||||
daemonIsPodman(env.id)
|
||||
]);
|
||||
return json({
|
||||
success: true,
|
||||
info: {
|
||||
serverVersion: info.ServerVersion,
|
||||
containers: info.Containers,
|
||||
images: info.Images,
|
||||
name: info.Name
|
||||
name: info.Name,
|
||||
engine: isPodman ? 'podman' : 'docker'
|
||||
},
|
||||
isEdgeMode: true,
|
||||
hawser: edgeConn ? {
|
||||
@@ -70,17 +75,20 @@ export const POST: RequestHandler = async ({ params }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// For Hawser Standard mode, fetch Docker info and Hawser info in parallel
|
||||
// (parallel calls are more efficient and avoid sequential connection issues)
|
||||
// Fetch Docker info, podman detection, and (for hawser-standard) hawser
|
||||
// info in parallel — faster, and avoids serializing remote calls.
|
||||
let info: any;
|
||||
let hawserInfo = null;
|
||||
let isPodman = false;
|
||||
if (env.connectionType === 'hawser-standard') {
|
||||
const [dockerResult, hawserResult] = await Promise.all([
|
||||
const [dockerResult, hawserResult, detected] = await Promise.all([
|
||||
getDockerInfo(env.id),
|
||||
getHawserInfo(id)
|
||||
getHawserInfo(id),
|
||||
daemonIsPodman(env.id)
|
||||
]);
|
||||
info = dockerResult;
|
||||
hawserInfo = hawserResult;
|
||||
isPodman = detected;
|
||||
if (hawserInfo?.hawserVersion) {
|
||||
await updateEnvironment(id, {
|
||||
hawserVersion: hawserInfo.hawserVersion,
|
||||
@@ -90,7 +98,12 @@ export const POST: RequestHandler = async ({ params }) => {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
info = await getDockerInfo(env.id);
|
||||
const [dockerResult, detected] = await Promise.all([
|
||||
getDockerInfo(env.id),
|
||||
daemonIsPodman(env.id)
|
||||
]);
|
||||
info = dockerResult;
|
||||
isPodman = detected;
|
||||
}
|
||||
|
||||
return json({
|
||||
@@ -99,7 +112,8 @@ export const POST: RequestHandler = async ({ params }) => {
|
||||
serverVersion: info.ServerVersion,
|
||||
containers: info.Containers,
|
||||
images: info.Images,
|
||||
name: info.Name
|
||||
name: info.Name,
|
||||
engine: isPodman ? 'podman' : 'docker'
|
||||
},
|
||||
hawser: hawserInfo
|
||||
});
|
||||
|
||||
@@ -90,6 +90,13 @@ export interface GeneralSettings {
|
||||
defaultComposeTemplate: string;
|
||||
// Label filter mode
|
||||
labelFilterMode: 'any' | 'all';
|
||||
// Whether to surface URLs inferred from reverse-proxy labels — currently
|
||||
// Traefik (traefik.http.routers.*) and Pangolin (pangolin.proxy-resources.*).
|
||||
// When false both parsers are bypassed and no proxy-derived pills are rendered.
|
||||
honorProxyLabels: boolean;
|
||||
// Whether to surface a "view changelog" link next to the update badge.
|
||||
// Resolved client-side from OCI labels / GHCR image names; no server hit.
|
||||
showImageChangelogLinks: boolean;
|
||||
// Whether spinning icons (animate-spin etc.) are animated (#1169)
|
||||
animateIcons: boolean;
|
||||
}
|
||||
@@ -124,6 +131,8 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
|
||||
labelFilterMode: 'any' as const,
|
||||
honorProxyLabels: true,
|
||||
showImageChangelogLinks: true,
|
||||
animateIcons: true,
|
||||
defaultComposeTemplate: `version: "3.8"
|
||||
|
||||
@@ -203,6 +212,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
defaultTrivyImage,
|
||||
defaultComposeTemplate,
|
||||
labelFilterMode,
|
||||
honorProxyLabels,
|
||||
showImageChangelogLinks,
|
||||
animateIcons
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
@@ -243,6 +254,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_compose_template'),
|
||||
getSetting('label_filter_mode'),
|
||||
getSetting('honor_proxy_labels'),
|
||||
getSetting('show_image_changelog_links'),
|
||||
getSetting('animate_icons')
|
||||
]);
|
||||
|
||||
@@ -287,6 +300,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
|
||||
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons
|
||||
};
|
||||
|
||||
@@ -305,7 +320,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, animateIcons } = body;
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -445,6 +460,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
|
||||
await setSetting('label_filter_mode', labelFilterMode);
|
||||
}
|
||||
if (honorProxyLabels !== undefined && typeof honorProxyLabels === 'boolean') {
|
||||
await setSetting('honor_proxy_labels', honorProxyLabels);
|
||||
}
|
||||
if (showImageChangelogLinks !== undefined && typeof showImageChangelogLinks === 'boolean') {
|
||||
await setSetting('show_image_changelog_links', showImageChangelogLinks);
|
||||
}
|
||||
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
|
||||
await setSetting('animate_icons', animateIcons);
|
||||
}
|
||||
@@ -489,6 +510,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
defaultTrivyImageVal,
|
||||
defaultComposeTemplateVal,
|
||||
labelFilterModeVal,
|
||||
honorProxyLabelsVal,
|
||||
showImageChangelogLinksVal,
|
||||
animateIconsVal
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
@@ -529,6 +552,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_compose_template'),
|
||||
getSetting('label_filter_mode'),
|
||||
getSetting('honor_proxy_labels'),
|
||||
getSetting('show_image_changelog_links'),
|
||||
getSetting('animate_icons')
|
||||
]);
|
||||
|
||||
@@ -573,6 +598,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
|
||||
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getVolumeArchive, releaseVolumeHelperContainer } from '$lib/server/docker';
|
||||
import { getVolumeArchive, releaseVolumeHelperContainer, statVolumePath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||
@@ -22,6 +23,18 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// For format=raw, check if the path is a single file. Directories fall back to tar.
|
||||
let isDir = false;
|
||||
if (format === 'raw' && path !== '/') {
|
||||
try {
|
||||
const stat = await statVolumePath(params.name, path, envIdNum);
|
||||
isDir = stat.isDir === true;
|
||||
} catch {
|
||||
// Stat failure: let the archive call below produce the real error
|
||||
}
|
||||
} else if (format === 'raw') {
|
||||
isDir = true; // root path '/' is always a directory
|
||||
}
|
||||
|
||||
const { response } = await getVolumeArchive(params.name, path, envIdNum);
|
||||
|
||||
@@ -35,7 +48,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
// Prepare response based on format
|
||||
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
|
||||
|
||||
if (format === 'tar.gz') {
|
||||
if (format === 'raw' && !isDir) {
|
||||
// Strip the tar wrapper and emit raw file bytes (#1180).
|
||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||
body = extractFirstFileFromTar(tarData);
|
||||
// Use the file's basename, not the volume-derived path-joined name.
|
||||
filename = path.split('/').filter(Boolean).pop() || filename;
|
||||
contentType = 'application/octet-stream';
|
||||
extension = '';
|
||||
|
||||
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
|
||||
} else if (format === 'tar.gz') {
|
||||
// Compress with gzip — fully consumes the archive stream
|
||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||
body = gzipSync(tarData);
|
||||
|
||||
Reference in new issue
Block a user