1.0.36
This commit is contained in:
63 files changed
+1324
-281
No files matched your search
@@ -2,6 +2,7 @@ import { json, redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { buildOidcAuthorizationUrl, isAuthEnabled } from '$lib/server/auth';
|
||||
import { getOidcConfig } from '$lib/server/db';
|
||||
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
|
||||
|
||||
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
@@ -15,8 +16,8 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
return json({ error: 'Invalid configuration ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectUrl = url.searchParams.get('redirect') || '/';
|
||||
// Get redirect URL from query params (validated path-relative only)
|
||||
const redirectUrl = safeRedirectOrRoot(url.searchParams.get('redirect'));
|
||||
|
||||
try {
|
||||
const config = await getOidcConfig(id);
|
||||
@@ -56,7 +57,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const redirectUrl = body.redirect || '/';
|
||||
const redirectUrl = safeRedirectOrRoot(body.redirect);
|
||||
|
||||
const config = await getOidcConfig(id);
|
||||
if (!config || !config.enabled) {
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
|
||||
|
||||
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
@@ -54,8 +55,8 @@ export const GET: RequestHandler = async (event) => {
|
||||
providerName: result.providerName
|
||||
});
|
||||
|
||||
// Redirect to the original destination or home
|
||||
const redirectUrl = result.redirectUrl || '/';
|
||||
// Redirect to the original destination or home (validated path-relative)
|
||||
const redirectUrl = safeRedirectOrRoot(result.redirectUrl);
|
||||
throw redirect(302, redirectUrl);
|
||||
} catch (error: any) {
|
||||
// Re-throw redirect
|
||||
|
||||
@@ -9,12 +9,20 @@ import {
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'view');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
try {
|
||||
const containerName = decodeURIComponent(params.containerName);
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
const envId = envIdParam ? parseInt(envIdParam) : undefined;
|
||||
|
||||
const envDenied = await auth.requireEnvAccess(envId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
const setting = await getAutoUpdateSetting(containerName, envId);
|
||||
|
||||
if (!setting) {
|
||||
@@ -41,15 +49,18 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'edit');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
try {
|
||||
const containerName = decodeURIComponent(params.containerName);
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
const envId = envIdParam ? parseInt(envIdParam) : undefined;
|
||||
|
||||
const envDenied = await auth.requireEnvAccess(envId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
const body = await request.json();
|
||||
// Accept both camelCase and snake_case for backward compatibility
|
||||
const enabled = body.enabled;
|
||||
@@ -109,15 +120,18 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'edit');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
try {
|
||||
const containerName = decodeURIComponent(params.containerName);
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
const envId = envIdParam ? parseInt(envIdParam) : undefined;
|
||||
|
||||
const envDenied = await auth.requireEnvAccess(envId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
// Get the setting ID before deleting
|
||||
const setting = await getAutoUpdateSetting(containerName, envId);
|
||||
const settingId = setting?.id;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
|
||||
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
@@ -42,9 +42,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
}
|
||||
|
||||
const allContainers = await listContainers(true, envIdNum);
|
||||
// Containers labeled dockhand.hidden=true are excluded from update checks —
|
||||
// they're invisible to the user, so we won't alert on updates for them either (#1083).
|
||||
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
|
||||
// Skip:
|
||||
// - dockhand.hidden=true (invisible to user, so no update alerts) (#1083)
|
||||
// - Podman pod-infra containers (localhost/podman-pause, never published) (#1221)
|
||||
const containers = allContainers.filter(
|
||||
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
|
||||
);
|
||||
|
||||
send('progress', { checked: 0, total: containers.length });
|
||||
|
||||
|
||||
@@ -258,11 +258,13 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
|
||||
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
|
||||
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
|
||||
|
||||
// Get latest metrics, event stats, and pending updates in parallel
|
||||
// Get latest metrics, event stats, and pending updates in parallel.
|
||||
// Each call has its own catch so one failed DB query (e.g. a corrupt
|
||||
// container_events index, #1210) doesn't poison the whole tile.
|
||||
const [latestMetrics, eventStats, pendingUpdates] = await Promise.all([
|
||||
getLatestHostMetrics(env.id),
|
||||
getContainerEventStats(env.id),
|
||||
getPendingContainerUpdates(env.id)
|
||||
getLatestHostMetrics(env.id).catch(() => null),
|
||||
getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })),
|
||||
getPendingContainerUpdates(env.id).catch(() => [])
|
||||
]);
|
||||
|
||||
if (latestMetrics) {
|
||||
|
||||
@@ -195,13 +195,15 @@ async function getEnvironmentStatsProgressive(
|
||||
|
||||
// Get all database stats in parallel for better performance
|
||||
// NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others
|
||||
// Instead, we determine online status from whether listContainers succeeds
|
||||
// Instead, we determine online status from whether listContainers succeeds.
|
||||
// Each call has its own catch so one failed DB query (e.g. a corrupt
|
||||
// container_events index, #1210) doesn't poison the whole tile.
|
||||
const [latestMetrics, eventStats, recentEventsResult, metricsHistory, pendingUpdates] = await Promise.all([
|
||||
getLatestHostMetrics(env.id),
|
||||
getContainerEventStats(env.id),
|
||||
getContainerEvents({ environmentId: env.id, limit: 10 }),
|
||||
getHostMetrics(metricsPointCount, env.id),
|
||||
getPendingContainerUpdates(env.id)
|
||||
getLatestHostMetrics(env.id).catch(() => null),
|
||||
getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })),
|
||||
getContainerEvents({ environmentId: env.id, limit: 10 }).catch(() => ({ events: [], total: 0, limit: 10, offset: 0 })),
|
||||
getHostMetrics(metricsPointCount, env.id).catch(() => []),
|
||||
getPendingContainerUpdates(env.id).catch(() => [])
|
||||
]);
|
||||
|
||||
if (latestMetrics) {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const permDenied = await auth.requirePermission('activity', 'view');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
const envDenied = await auth.requireEnvAccess(envIdNum);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
// Early return if no environment specified
|
||||
if (!envIdNum) {
|
||||
return new Response(
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
/**
|
||||
* Database Health Check Endpoint
|
||||
*
|
||||
* Returns detailed information about the database schema state,
|
||||
* including migration status, table existence, and connection info.
|
||||
* Public endpoint suitable for external monitoring. The public payload reports
|
||||
* enough detail to detect schema drift and table loss without exposing
|
||||
* connection details (host, port, db name, user) or the running migration tag.
|
||||
*
|
||||
* Authenticated callers with settings:view get the full payload — connection
|
||||
* string (password masked) and schema version included — which is useful for
|
||||
* operators debugging from the admin UI.
|
||||
*
|
||||
* GET /api/health/database
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* healthy: boolean,
|
||||
* database: 'sqlite' | 'postgresql',
|
||||
* connection: string,
|
||||
* migrationsTable: boolean,
|
||||
* appliedMigrations: number,
|
||||
* pendingMigrations: number,
|
||||
* schemaVersion: string | null,
|
||||
* tables: {
|
||||
* expected: number,
|
||||
* found: number,
|
||||
* missing: string[]
|
||||
* },
|
||||
* timestamp: string
|
||||
* }
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { checkSchemaHealth } from '$lib/server/db/drizzle';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
try {
|
||||
const health = await checkSchemaHealth();
|
||||
|
||||
return json(health, {
|
||||
const auth = await authorize(cookies);
|
||||
const showFullDetail = !auth.authEnabled
|
||||
|| (auth.isAuthenticated && await auth.can('settings', 'view'));
|
||||
|
||||
const payload = showFullDetail
|
||||
? health
|
||||
: {
|
||||
healthy: health.healthy,
|
||||
database: health.database,
|
||||
migrationsTable: health.migrationsTable,
|
||||
appliedMigrations: health.appliedMigrations,
|
||||
pendingMigrations: health.pendingMigrations,
|
||||
tables: health.tables,
|
||||
timestamp: health.timestamp
|
||||
};
|
||||
|
||||
return json(payload, {
|
||||
status: health.healthy ? 200 : 503,
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
||||
|
||||
@@ -45,7 +45,9 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
|
||||
const validTerminalFontIds = monospaceFonts.map(f => f.id);
|
||||
const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
|
||||
|
||||
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean } = {};
|
||||
const validActionIconSizes = ['small', 'normal', 'large', 'xlarge'];
|
||||
|
||||
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string } = {};
|
||||
|
||||
if (data.lightTheme !== undefined) {
|
||||
if (!validLightThemeIds.includes(data.lightTheme)) {
|
||||
@@ -103,6 +105,20 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
|
||||
updates.animateIcons = data.animateIcons;
|
||||
}
|
||||
|
||||
if (data.coloredActionButtons !== undefined) {
|
||||
if (typeof data.coloredActionButtons !== 'boolean') {
|
||||
return json({ error: 'Invalid coloredActionButtons' }, { status: 400 });
|
||||
}
|
||||
updates.coloredActionButtons = data.coloredActionButtons;
|
||||
}
|
||||
|
||||
if (data.actionIconSize !== undefined) {
|
||||
if (!validActionIconSizes.includes(data.actionIconSize)) {
|
||||
return json({ error: 'Invalid actionIconSize' }, { status: 400 });
|
||||
}
|
||||
updates.actionIconSize = data.actionIconSize;
|
||||
}
|
||||
|
||||
await setUserThemePreferences(currentUser.id, updates);
|
||||
|
||||
// Return updated preferences
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { setDefaultRegistry, getRegistry } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('settings', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getRegistry } from '$lib/server/db';
|
||||
import { getRegistryAuth } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
function isDockerHub(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
@@ -18,7 +19,12 @@ const MANIFEST_TYPES = [
|
||||
'application/vnd.oci.image.index.v1+json'
|
||||
];
|
||||
|
||||
export const DELETE: RequestHandler = async ({ url }) => {
|
||||
export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('settings', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const registryId = url.searchParams.get('registry');
|
||||
const imageName = url.searchParams.get('image');
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '$lib/server/db';
|
||||
import { getNextRun, getSystemSchedules } from '$lib/server/scheduler';
|
||||
import { getGlobalScannerDefaults, getScannerSettingsWithDefaults } from '$lib/server/scanner';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export interface ScheduleInfo {
|
||||
id: number;
|
||||
@@ -43,7 +44,12 @@ export interface ScheduleInfo {
|
||||
vulnerabilityCriteria?: VulnerabilityCriteria | null;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'view');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
try {
|
||||
const schedules: ScheduleInfo[] = [];
|
||||
|
||||
@@ -233,13 +239,23 @@ export const GET: RequestHandler = async () => {
|
||||
);
|
||||
schedules.push(...sysSchedules);
|
||||
|
||||
// Filter by per-env access (enterprise): caller sees only schedules
|
||||
// for envs they can access, plus all system schedules (env null).
|
||||
// Free edition / admin: getAccessibleEnvironmentIds returns null,
|
||||
// no filtering applied.
|
||||
const accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
|
||||
const filtered = accessibleEnvIds === null
|
||||
? schedules
|
||||
: schedules.filter(s =>
|
||||
s.environmentId === null || accessibleEnvIds.includes(s.environmentId));
|
||||
|
||||
// Sort: system jobs last, then by name
|
||||
schedules.sort((a, b) => {
|
||||
filtered.sort((a, b) => {
|
||||
if (a.isSystem !== b.isSystem) return a.isSystem ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return json({ schedules });
|
||||
return json({ schedules: filtered });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get schedules:', error);
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { RequestHandler } from './$types';
|
||||
import {
|
||||
getAutoUpdateSettingById,
|
||||
deleteAutoUpdateSchedule,
|
||||
getGitStack,
|
||||
updateGitStack,
|
||||
deleteEnvUpdateCheckSettings,
|
||||
deleteImagePruneSettings
|
||||
@@ -17,9 +18,9 @@ import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'edit');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
try {
|
||||
const { type, id } = params;
|
||||
@@ -33,6 +34,8 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
// Hard delete container schedule
|
||||
const schedule = await getAutoUpdateSettingById(scheduleId);
|
||||
if (schedule) {
|
||||
const envDenied = await auth.requireEnvAccess(schedule.environmentId);
|
||||
if (envDenied) return envDenied;
|
||||
await deleteAutoUpdateSchedule(schedule.containerName, schedule.environmentId ?? undefined);
|
||||
// Unregister from croner
|
||||
unregisterSchedule(scheduleId, 'container_update');
|
||||
@@ -40,6 +43,12 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
return json({ success: true });
|
||||
|
||||
} else if (type === 'git_stack_sync') {
|
||||
const stack = await getGitStack(scheduleId);
|
||||
if (!stack) {
|
||||
return json({ error: 'Schedule not found' }, { status: 404 });
|
||||
}
|
||||
const envDenied = await auth.requireEnvAccess(stack.environmentId);
|
||||
if (envDenied) return envDenied;
|
||||
// Disable auto-update for git stack (don't delete the stack itself)
|
||||
await updateGitStack(scheduleId, {
|
||||
autoUpdate: false,
|
||||
@@ -52,12 +61,16 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
|
||||
} else if (type === 'env_update_check') {
|
||||
// Delete env update check settings (scheduleId is environmentId)
|
||||
const envDenied = await auth.requireEnvAccess(scheduleId);
|
||||
if (envDenied) return envDenied;
|
||||
await deleteEnvUpdateCheckSettings(scheduleId);
|
||||
unregisterSchedule(scheduleId, 'env_update_check');
|
||||
return json({ success: true });
|
||||
|
||||
} else if (type === 'image_prune') {
|
||||
// Delete image prune settings (scheduleId is environmentId)
|
||||
const envDenied = await auth.requireEnvAccess(scheduleId);
|
||||
if (envDenied) return envDenied;
|
||||
await deleteImagePruneSettings(scheduleId);
|
||||
unregisterSchedule(scheduleId, 'image_prune');
|
||||
return json({ success: true });
|
||||
|
||||
@@ -12,12 +12,13 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getAutoUpdateSettingById, getGitStack } from '$lib/server/db';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'run')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'run');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
try {
|
||||
const { type, id } = params;
|
||||
@@ -27,6 +28,37 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
return json({ error: 'Invalid schedule ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Resolve schedule → environmentId so we can enforce per-env access
|
||||
// before triggering. System schedules (env null) are gated only by
|
||||
// the global schedules:run check above.
|
||||
let scheduleEnvId: number | null = null;
|
||||
switch (type) {
|
||||
case 'container_update': {
|
||||
const setting = await getAutoUpdateSettingById(scheduleId);
|
||||
if (!setting) return json({ error: 'Schedule not found' }, { status: 404 });
|
||||
scheduleEnvId = setting.environmentId;
|
||||
break;
|
||||
}
|
||||
case 'git_stack_sync': {
|
||||
const stack = await getGitStack(scheduleId);
|
||||
if (!stack) return json({ error: 'Schedule not found' }, { status: 404 });
|
||||
scheduleEnvId = stack.environmentId;
|
||||
break;
|
||||
}
|
||||
case 'env_update_check':
|
||||
case 'image_prune':
|
||||
scheduleEnvId = scheduleId;
|
||||
break;
|
||||
case 'system_cleanup':
|
||||
scheduleEnvId = null;
|
||||
break;
|
||||
default:
|
||||
return json({ error: 'Invalid schedule type' }, { status: 400 });
|
||||
}
|
||||
|
||||
const envDenied = await auth.requireEnvAccess(scheduleEnvId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
let result: { success: boolean; executionId?: number; error?: string };
|
||||
|
||||
switch (type) {
|
||||
@@ -46,6 +78,7 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
result = await triggerImagePrune(scheduleId);
|
||||
break;
|
||||
default:
|
||||
// Unreachable — validated in the resolution switch above.
|
||||
return json({ error: 'Invalid schedule type' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'edit');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
try {
|
||||
const { type, id } = params;
|
||||
@@ -28,6 +28,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
if (!setting) {
|
||||
return json({ error: 'Schedule not found' }, { status: 404 });
|
||||
}
|
||||
const envDenied = await auth.requireEnvAccess(setting.environmentId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
const newEnabled = !setting.enabled;
|
||||
await updateAutoUpdateSettingById(scheduleId, {
|
||||
@@ -47,6 +49,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
if (!stack) {
|
||||
return json({ error: 'Schedule not found' }, { status: 404 });
|
||||
}
|
||||
const envDenied = await auth.requireEnvAccess(stack.environmentId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
const newEnabled = !stack.autoUpdate;
|
||||
await updateGitStack(scheduleId, {
|
||||
@@ -63,6 +67,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
return json({ success: true, enabled: newEnabled });
|
||||
} else if (type === 'env_update_check') {
|
||||
// scheduleId is environmentId for env update check
|
||||
const envDenied = await auth.requireEnvAccess(scheduleId);
|
||||
if (envDenied) return envDenied;
|
||||
const config = await getEnvUpdateCheckSettings(scheduleId);
|
||||
if (!config) {
|
||||
return json({ error: 'Schedule not found' }, { status: 404 });
|
||||
@@ -84,6 +90,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
return json({ success: true, enabled: newEnabled });
|
||||
} else if (type === 'image_prune') {
|
||||
// scheduleId is environmentId for image prune
|
||||
const envDenied = await auth.requireEnvAccess(scheduleId);
|
||||
if (envDenied) return envDenied;
|
||||
const config = await getImagePruneSettings(scheduleId);
|
||||
if (!config) {
|
||||
return json({ error: 'Schedule not found' }, { status: 404 });
|
||||
|
||||
@@ -40,11 +40,16 @@ export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
export type DownloadFormat = 'tar' | 'tar.gz' | 'raw';
|
||||
export type EventCollectionMode = 'stream' | 'poll';
|
||||
export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge';
|
||||
|
||||
const VALID_ACTION_ICON_SIZES: ActionIconSize[] = ['small', 'normal', 'large', 'xlarge'];
|
||||
|
||||
export interface GeneralSettings {
|
||||
confirmDestructive: boolean;
|
||||
showStoppedContainers: boolean;
|
||||
highlightUpdates: boolean;
|
||||
coloredActionButtons: boolean;
|
||||
actionIconSize: ActionIconSize;
|
||||
timeFormat: TimeFormat;
|
||||
dateFormat: DateFormat;
|
||||
downloadFormat: DownloadFormat;
|
||||
@@ -102,12 +107,17 @@ export interface GeneralSettings {
|
||||
animateIcons: boolean;
|
||||
// Skip Dockhand's scanner images (grype, trivy) during 'prune all unused' (#625)
|
||||
protectScannerImages: boolean;
|
||||
// Scanner Advanced settings (#1219). Empty = use auto-detection.
|
||||
defaultScannerNetworkMode: string;
|
||||
defaultScannerDns: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
|
||||
confirmDestructive: true,
|
||||
showStoppedContainers: true,
|
||||
highlightUpdates: true,
|
||||
coloredActionButtons: false,
|
||||
actionIconSize: 'normal' as const,
|
||||
timeFormat: '24h',
|
||||
dateFormat: 'DD.MM.YYYY',
|
||||
downloadFormat: 'tar',
|
||||
@@ -138,6 +148,8 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
showImageChangelogLinks: true,
|
||||
animateIcons: true,
|
||||
protectScannerImages: true,
|
||||
defaultScannerNetworkMode: '',
|
||||
defaultScannerDns: [],
|
||||
defaultComposeTemplate: `version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -167,6 +179,24 @@ const VALID_EDITOR_FONTS = VALID_TERMINAL_FONTS;
|
||||
|
||||
const VALID_DATE_FORMATS: DateFormat[] = ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD', 'DD.MM.YYYY'];
|
||||
|
||||
// Scanner DNS is stored as a JSON-stringified array in the settings KV store
|
||||
// (consistent with externalStackPaths). Tolerate the legacy/hand-edited
|
||||
// comma-separated form too.
|
||||
function parseScannerDnsStorage(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return [];
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
// UI preferences (time format, date format) should be available to all authenticated users
|
||||
@@ -181,6 +211,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
confirmDestructive,
|
||||
showStoppedContainers,
|
||||
highlightUpdates,
|
||||
coloredActionButtons,
|
||||
actionIconSize,
|
||||
timeFormat,
|
||||
dateFormat,
|
||||
downloadFormat,
|
||||
@@ -219,11 +251,15 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
honorProxyLabels,
|
||||
showImageChangelogLinks,
|
||||
animateIcons,
|
||||
protectScannerImages
|
||||
protectScannerImages,
|
||||
defaultScannerNetworkMode,
|
||||
defaultScannerDnsRaw
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
getSetting('highlight_updates'),
|
||||
getSetting('colored_action_buttons'),
|
||||
getSetting('action_icon_size'),
|
||||
getSetting('time_format'),
|
||||
getSetting('date_format'),
|
||||
getSetting('download_format'),
|
||||
@@ -262,13 +298,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getSetting('honor_proxy_labels'),
|
||||
getSetting('show_image_changelog_links'),
|
||||
getSetting('animate_icons'),
|
||||
getSetting('protect_scanner_images')
|
||||
getSetting('protect_scanner_images'),
|
||||
getSetting('default_scanner_network_mode'),
|
||||
getSetting('default_scanner_dns')
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
confirmDestructive: confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive,
|
||||
showStoppedContainers: showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers,
|
||||
highlightUpdates: highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates,
|
||||
coloredActionButtons: coloredActionButtons ?? DEFAULT_SETTINGS.coloredActionButtons,
|
||||
actionIconSize: (VALID_ACTION_ICON_SIZES.includes(actionIconSize as ActionIconSize) ? actionIconSize : DEFAULT_SETTINGS.actionIconSize) as ActionIconSize,
|
||||
timeFormat: timeFormat ?? DEFAULT_SETTINGS.timeFormat,
|
||||
dateFormat: dateFormat ?? DEFAULT_SETTINGS.dateFormat,
|
||||
downloadFormat: downloadFormat ?? DEFAULT_SETTINGS.downloadFormat,
|
||||
@@ -309,7 +349,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons,
|
||||
protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages
|
||||
protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
|
||||
defaultScannerNetworkMode: defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
|
||||
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRaw)
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
@@ -327,7 +369,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, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages } = body;
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, 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, protectScannerImages, defaultScannerNetworkMode, defaultScannerDns } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -338,6 +380,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (highlightUpdates !== undefined) {
|
||||
await setSetting('highlight_updates', highlightUpdates);
|
||||
}
|
||||
if (coloredActionButtons !== undefined) {
|
||||
await setSetting('colored_action_buttons', coloredActionButtons);
|
||||
}
|
||||
if (actionIconSize !== undefined && VALID_ACTION_ICON_SIZES.includes(actionIconSize)) {
|
||||
await setSetting('action_icon_size', actionIconSize);
|
||||
}
|
||||
if (timeFormat !== undefined && (timeFormat === '12h' || timeFormat === '24h')) {
|
||||
await setSetting('time_format', timeFormat);
|
||||
}
|
||||
@@ -479,12 +527,28 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (protectScannerImages !== undefined && typeof protectScannerImages === 'boolean') {
|
||||
await setSetting('protect_scanner_images', protectScannerImages);
|
||||
}
|
||||
if (defaultScannerNetworkMode !== undefined && typeof defaultScannerNetworkMode === 'string') {
|
||||
// Free-form network mode (host / bridge / none / custom-network-name / '').
|
||||
// Trim and cap length to avoid pathological values from a corrupted UI.
|
||||
const trimmed = defaultScannerNetworkMode.trim().slice(0, 200);
|
||||
await setSetting('default_scanner_network_mode', trimmed);
|
||||
}
|
||||
if (defaultScannerDns !== undefined && Array.isArray(defaultScannerDns)) {
|
||||
const cleaned = defaultScannerDns
|
||||
.filter((d) => typeof d === 'string')
|
||||
.map((d: string) => d.trim())
|
||||
.filter((d) => d.length > 0)
|
||||
.slice(0, 10); // sane upper bound; Docker accepts more but no one needs it
|
||||
await setSetting('default_scanner_dns', JSON.stringify(cleaned));
|
||||
}
|
||||
|
||||
// Fetch all settings in parallel for the response
|
||||
const [
|
||||
confirmDestructiveVal,
|
||||
showStoppedContainersVal,
|
||||
highlightUpdatesVal,
|
||||
coloredActionButtonsVal,
|
||||
actionIconSizeVal,
|
||||
timeFormatVal,
|
||||
dateFormatVal,
|
||||
downloadFormatVal,
|
||||
@@ -523,11 +587,15 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
honorProxyLabelsVal,
|
||||
showImageChangelogLinksVal,
|
||||
animateIconsVal,
|
||||
protectScannerImagesVal
|
||||
protectScannerImagesVal,
|
||||
defaultScannerNetworkModeVal,
|
||||
defaultScannerDnsRawVal
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
getSetting('highlight_updates'),
|
||||
getSetting('colored_action_buttons'),
|
||||
getSetting('action_icon_size'),
|
||||
getSetting('time_format'),
|
||||
getSetting('date_format'),
|
||||
getSetting('download_format'),
|
||||
@@ -566,13 +634,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getSetting('honor_proxy_labels'),
|
||||
getSetting('show_image_changelog_links'),
|
||||
getSetting('animate_icons'),
|
||||
getSetting('protect_scanner_images')
|
||||
getSetting('protect_scanner_images'),
|
||||
getSetting('default_scanner_network_mode'),
|
||||
getSetting('default_scanner_dns')
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
confirmDestructive: confirmDestructiveVal ?? DEFAULT_SETTINGS.confirmDestructive,
|
||||
showStoppedContainers: showStoppedContainersVal ?? DEFAULT_SETTINGS.showStoppedContainers,
|
||||
highlightUpdates: highlightUpdatesVal ?? DEFAULT_SETTINGS.highlightUpdates,
|
||||
coloredActionButtons: coloredActionButtonsVal ?? DEFAULT_SETTINGS.coloredActionButtons,
|
||||
actionIconSize: (VALID_ACTION_ICON_SIZES.includes(actionIconSizeVal as ActionIconSize) ? actionIconSizeVal : DEFAULT_SETTINGS.actionIconSize) as ActionIconSize,
|
||||
timeFormat: timeFormatVal ?? DEFAULT_SETTINGS.timeFormat,
|
||||
dateFormat: dateFormatVal ?? DEFAULT_SETTINGS.dateFormat,
|
||||
downloadFormat: downloadFormatVal ?? DEFAULT_SETTINGS.downloadFormat,
|
||||
@@ -613,7 +685,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages,
|
||||
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
|
||||
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons,
|
||||
defaultScannerNetworkMode: defaultScannerNetworkModeVal ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
|
||||
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRawVal)
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
|
||||
@@ -14,7 +14,9 @@ const DEFAULT_THEME_SETTINGS = {
|
||||
gridFontSize: 'normal',
|
||||
terminalFont: 'system-mono',
|
||||
editorFont: 'system-mono',
|
||||
animateIcons: true
|
||||
animateIcons: true,
|
||||
coloredActionButtons: false,
|
||||
actionIconSize: 'normal'
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
@@ -27,7 +29,9 @@ export const GET: RequestHandler = async () => {
|
||||
gridFontSize,
|
||||
terminalFont,
|
||||
editorFont,
|
||||
animateIcons
|
||||
animateIcons,
|
||||
coloredActionButtons,
|
||||
actionIconSize
|
||||
] = await Promise.all([
|
||||
getSetting('theme_light'),
|
||||
getSetting('theme_dark'),
|
||||
@@ -36,7 +40,9 @@ export const GET: RequestHandler = async () => {
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font'),
|
||||
getSetting('theme_editor_font'),
|
||||
getSetting('animate_icons')
|
||||
getSetting('animate_icons'),
|
||||
getSetting('colored_action_buttons'),
|
||||
getSetting('action_icon_size')
|
||||
]);
|
||||
|
||||
return json({
|
||||
@@ -47,7 +53,9 @@ export const GET: RequestHandler = async () => {
|
||||
gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize,
|
||||
terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont,
|
||||
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont,
|
||||
animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons
|
||||
animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons,
|
||||
coloredActionButtons: coloredActionButtons ?? DEFAULT_THEME_SETTINGS.coloredActionButtons,
|
||||
actionIconSize: actionIconSize ?? DEFAULT_THEME_SETTINGS.actionIconSize
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get theme settings:', error);
|
||||
|
||||
@@ -20,6 +20,47 @@ export interface TemplateItem {
|
||||
stars?: number;
|
||||
pulls?: number;
|
||||
network?: string;
|
||||
/** Free-form maintainer / install notes from the upstream catalog. */
|
||||
note?: string;
|
||||
/** Project / homepage URL the user can click to learn more or report issues (#1211). */
|
||||
projectUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort first-URL extraction from a template's description or note.
|
||||
* Order matters: markdown link first, then HTML href, then a bare URL.
|
||||
*/
|
||||
function extractFirstUrl(text: string | undefined | null): string | null {
|
||||
if (!text) return null;
|
||||
const md = text.match(/\[[^\]]+\]\((https?:\/\/[^)]+)\)/);
|
||||
if (md) return md[1];
|
||||
const html = text.match(/<a\s+[^>]*href=["']([^"']+)["']/i);
|
||||
if (html) return html[1];
|
||||
const plain = text.match(/(https?:\/\/[^\s<>"')]+)/);
|
||||
if (plain) return plain[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the "Project" URL surfaced on the card.
|
||||
* Priority:
|
||||
* 1. URL embedded in `description` — LSIO-style templates have a markdown
|
||||
* link to the actual project repo here, which is what users want most.
|
||||
* 2. URL embedded in `note` — Portainer v3 catalogs often put a homepage
|
||||
* link in the install notes.
|
||||
* 3. `maintainer` field — Lissy93's templates carry a per-template
|
||||
* maintainer URL; not always the project itself but usually close.
|
||||
* 4. `repository.url` — only for stack-type templates, points to the repo
|
||||
* hosting the compose file (closest thing we have).
|
||||
*/
|
||||
function resolveProjectUrl(entry: any, description: string, note: string): string | undefined {
|
||||
return (
|
||||
extractFirstUrl(description) ||
|
||||
extractFirstUrl(note) ||
|
||||
(typeof entry.maintainer === 'string' ? entry.maintainer.trim() : null) ||
|
||||
(entry.repository && typeof entry.repository.url === 'string' ? entry.repository.url : null) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Server-side cache: url → { data, fetchedAt }
|
||||
@@ -43,14 +84,18 @@ function normalizePortainerTemplate(entry: any, sourceName: string): TemplateIte
|
||||
if (entry.type === 2) return null;
|
||||
|
||||
const title = entry.title || entry.name || '';
|
||||
const description = entry.description || '';
|
||||
const note = typeof entry.note === 'string' ? entry.note : '';
|
||||
const template: TemplateItem = {
|
||||
id: hashId(sourceName, title),
|
||||
type: entry.type === 3 ? 'stack' : 'container',
|
||||
title,
|
||||
description: entry.description || '',
|
||||
description,
|
||||
logo: entry.logo || '',
|
||||
categories: Array.isArray(entry.categories) ? entry.categories : [],
|
||||
source: sourceName,
|
||||
note: note || undefined,
|
||||
projectUrl: resolveProjectUrl(entry, description, note)
|
||||
};
|
||||
|
||||
if (entry.type === 3 && entry.repository) {
|
||||
@@ -77,11 +122,18 @@ function normalizePortainerTemplate(entry: any, sourceName: string): TemplateIte
|
||||
function normalizeLinuxServerTemplate(entry: any): TemplateItem | null {
|
||||
if (!entry.name || entry.deprecated) return null;
|
||||
|
||||
const description = entry.description || '';
|
||||
// LSIO fleet entries sometimes carry a project_url; prefer it over regex sniffing.
|
||||
const projectUrl =
|
||||
(typeof entry.project_url === 'string' && entry.project_url) ||
|
||||
extractFirstUrl(description) ||
|
||||
`https://github.com/linuxserver/docker-${entry.name}`;
|
||||
|
||||
return {
|
||||
id: hashId('LinuxServer.io', entry.name),
|
||||
type: 'container',
|
||||
title: entry.name,
|
||||
description: entry.description || '',
|
||||
description,
|
||||
logo: entry.project_logo || '',
|
||||
categories: entry.category ? entry.category.split(',').map((c: string) => c.trim()).filter(Boolean) : [],
|
||||
source: 'LinuxServer.io',
|
||||
@@ -94,6 +146,7 @@ function normalizeLinuxServerTemplate(entry: any): TemplateItem | null {
|
||||
restartPolicy: 'unless-stopped',
|
||||
stars: entry.stars,
|
||||
pulls: entry.monthly_pulls,
|
||||
projectUrl
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user