mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-03 15:37:44 +00:00
6ac02c792a
Two small hardenings flagged by CodeQL:
- routes/registries.ts: drop the redundant startsWith block-list and rely
solely on URL parsing + protocol allow-list. The startsWith pass was
unreachable defense (any non-http/https scheme already fails the
protocol check below it) and was tripping js/incomplete-url-scheme-check.
- services/TemplateService.ts: replace the .includes('api.linuxserver.io')
substring match with new URL(registryUrl).hostname comparison. The
substring form would mis-classify a malicious admin-set URL like
https://evil.example/api.linuxserver.io/... as the LSIO registry and
apply the LSIO response parser to its payload. Hostname compare closes
that.
Behavioral parity for the happy path: every previously-accepted URL still
parses; the LSIO branch still triggers when the hostname is exactly
api.linuxserver.io.
355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
import axios from 'axios';
|
|
import { DatabaseService } from './DatabaseService';
|
|
import { CacheService } from './CacheService';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
|
|
|
|
interface TemplateEnv {
|
|
name: string;
|
|
label?: string;
|
|
default?: string;
|
|
}
|
|
|
|
interface TemplateVolume {
|
|
container: string;
|
|
bind?: string;
|
|
readonly?: boolean;
|
|
}
|
|
|
|
export interface Template {
|
|
type?: number;
|
|
title: string;
|
|
description: string;
|
|
logo?: string;
|
|
image?: string;
|
|
ports?: string[];
|
|
volumes?: TemplateVolume[] | string[];
|
|
env?: TemplateEnv[];
|
|
categories?: string[];
|
|
platform?: string;
|
|
github_url?: string;
|
|
docs_url?: string;
|
|
architectures?: string[];
|
|
stars?: number;
|
|
source?: string;
|
|
repository?: {
|
|
url: string;
|
|
stackfile: string;
|
|
};
|
|
}
|
|
|
|
interface TemplatesResponse {
|
|
version: string;
|
|
templates: Template[];
|
|
}
|
|
|
|
// Typed shapes for the LinuxServer.io API response
|
|
interface LsioPort { external?: number; internal: number; protocol?: string }
|
|
interface LsioVolume { path: string }
|
|
interface LsioEnvVar { name: string; desc?: string; default?: string }
|
|
interface LsioAppConfig { ports?: LsioPort[]; volumes?: LsioVolume[]; environment?: LsioEnvVar[] }
|
|
interface LsioApp {
|
|
name: string;
|
|
description?: string;
|
|
logo?: string;
|
|
github?: string;
|
|
readme?: string;
|
|
arch?: string[];
|
|
stars?: number;
|
|
config?: LsioAppConfig;
|
|
}
|
|
interface LsioApiResponse {
|
|
data?: { repositories?: { linuxserver?: Record<string, LsioApp> } };
|
|
}
|
|
|
|
// Static category map for LSIO apps (the LSIO API does not expose category metadata).
|
|
// Apps can belong to multiple categories. Unmapped apps fall back to ['Other'].
|
|
const LSIO_CATEGORY_MAP: Record<string, string[]> = {
|
|
// Media Servers
|
|
'plex': ['Media'],
|
|
'jellyfin': ['Media'],
|
|
'emby': ['Media'],
|
|
'navidrome': ['Media'],
|
|
'airsonic-advanced': ['Media'],
|
|
'airsonic': ['Media'],
|
|
'beets': ['Media'],
|
|
'calibre': ['Media', 'Books'],
|
|
'calibre-web': ['Media', 'Books'],
|
|
'kavita': ['Media', 'Books'],
|
|
'komga': ['Media', 'Books'],
|
|
'mylar3': ['Media', 'Books'],
|
|
'ubooquity': ['Media', 'Books'],
|
|
'lazylibrarian': ['Media', 'Books'],
|
|
'cops': ['Media', 'Books'],
|
|
'photoprism': ['Media', 'Productivity'],
|
|
'immich': ['Media', 'Productivity'],
|
|
'piwigo': ['Media'],
|
|
'lychee': ['Media'],
|
|
'davos': ['Media'],
|
|
'mstream': ['Media'],
|
|
'koel': ['Media'],
|
|
'grocy': ['Productivity'],
|
|
// *arr Automation suite
|
|
'sonarr': ['Automation', 'Media'],
|
|
'radarr': ['Automation', 'Media'],
|
|
'lidarr': ['Automation', 'Media'],
|
|
'readarr': ['Automation', 'Media'],
|
|
'bazarr': ['Automation', 'Media'],
|
|
'whisparr': ['Automation', 'Media'],
|
|
'prowlarr': ['Automation'],
|
|
'jackett': ['Automation'],
|
|
'nzbhydra2': ['Automation'],
|
|
'overseerr': ['Automation', 'Media'],
|
|
'ombi': ['Automation', 'Media'],
|
|
'requestrr': ['Automation'],
|
|
'tautulli': ['Monitoring', 'Media'],
|
|
'organizr': ['Automation'],
|
|
'recyclarr': ['Automation'],
|
|
'notifiarr': ['Automation'],
|
|
'unpackerr': ['Automation'],
|
|
// Dashboards / Homepages
|
|
'heimdall': ['Utilities'],
|
|
'homer': ['Utilities'],
|
|
'dasherr': ['Utilities'],
|
|
'flame': ['Utilities'],
|
|
'homarr': ['Utilities'],
|
|
'dashdot': ['Monitoring'],
|
|
// Downloaders
|
|
'qbittorrent': ['Downloaders'],
|
|
'transmission': ['Downloaders'],
|
|
'deluge': ['Downloaders'],
|
|
'sabnzbd': ['Downloaders'],
|
|
'nzbget': ['Downloaders'],
|
|
'aria2': ['Downloaders'],
|
|
'jdownloader-2': ['Downloaders'],
|
|
'pyload-ng': ['Downloaders'],
|
|
'rutorrent': ['Downloaders'],
|
|
'flood': ['Downloaders'],
|
|
'medusa': ['Automation', 'Downloaders'],
|
|
'sickchill': ['Automation', 'Downloaders'],
|
|
// Monitoring
|
|
'grafana': ['Monitoring'],
|
|
'netdata': ['Monitoring'],
|
|
'uptime-kuma': ['Monitoring'],
|
|
'statping-ng': ['Monitoring'],
|
|
'healthchecks': ['Monitoring'],
|
|
'smokeping': ['Monitoring'],
|
|
'librespeed': ['Monitoring'],
|
|
'speedtest-tracker': ['Monitoring'],
|
|
'scrutiny': ['Monitoring'],
|
|
'prometheus': ['Monitoring'],
|
|
'loki': ['Monitoring'],
|
|
'influxdb': ['Monitoring'],
|
|
// Networking / Reverse Proxy
|
|
'nginx': ['Networking'],
|
|
'swag': ['Networking'],
|
|
'letsencrypt': ['Networking'],
|
|
'ddclient': ['Networking'],
|
|
'duckdns': ['Networking'],
|
|
'wireguard': ['Networking', 'Security'],
|
|
'openvpn-as': ['Networking', 'Security'],
|
|
'netbootxyz': ['Networking'],
|
|
'pihole': ['Networking'],
|
|
'unbound': ['Networking'],
|
|
'adguardhome': ['Networking'],
|
|
'cloudflared': ['Networking'],
|
|
'haproxy': ['Networking'],
|
|
'traefik': ['Networking'],
|
|
'nginx-proxy-manager': ['Networking'],
|
|
'fail2ban': ['Networking', 'Security'],
|
|
// Security / Auth
|
|
'vaultwarden': ['Security'],
|
|
'authelia': ['Security'],
|
|
'lldap': ['Security'],
|
|
'endlessh': ['Security'],
|
|
'sshwifty': ['Security'],
|
|
// Development / CI
|
|
'gitea': ['Development'],
|
|
'code-server': ['Development'],
|
|
'drone': ['Development'],
|
|
'drone-runner-docker': ['Development'],
|
|
'registry': ['Development'],
|
|
'jenkins': ['Development'],
|
|
'gogs': ['Development'],
|
|
'woodpecker-ci': ['Development'],
|
|
'gitlab': ['Development'],
|
|
'fleet': ['Development'],
|
|
// Productivity / Self-hosted SaaS
|
|
'nextcloud': ['Productivity'],
|
|
'bookstack': ['Productivity', 'Documentation'],
|
|
'dokuwiki': ['Productivity', 'Documentation'],
|
|
'wikijs': ['Productivity', 'Documentation'],
|
|
'paperless-ngx': ['Productivity'],
|
|
'mealie': ['Productivity'],
|
|
'freshrss': ['Productivity'],
|
|
'miniflux': ['Productivity'],
|
|
'wallabag': ['Productivity'],
|
|
'trilium': ['Productivity'],
|
|
'hedgedoc': ['Productivity'],
|
|
'etherpad': ['Productivity'],
|
|
'monica': ['Productivity'],
|
|
'firefly-iii': ['Productivity'],
|
|
'shlink': ['Productivity'],
|
|
'yourls': ['Productivity'],
|
|
'stirling-pdf': ['Productivity'],
|
|
'syncthing': ['Productivity'],
|
|
'tandoor': ['Productivity'],
|
|
'linkwarden': ['Productivity'],
|
|
'vikunja': ['Productivity'],
|
|
// Utilities / Backup
|
|
'duplicati': ['Utilities'],
|
|
'restic': ['Utilities'],
|
|
'rsnapshot': ['Utilities'],
|
|
'mysql-workbench': ['Utilities'],
|
|
'sqlitebrowser': ['Utilities'],
|
|
'filezilla': ['Utilities'],
|
|
'rdesktop': ['Utilities'],
|
|
'webtop': ['Utilities'],
|
|
};
|
|
|
|
function getCategoriesForApp(name: string): string[] {
|
|
return LSIO_CATEGORY_MAP[name.toLowerCase()] ?? ['Other'];
|
|
}
|
|
|
|
export class TemplateService {
|
|
private static readonly CACHE_KEY = 'templates:all';
|
|
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
public clearCache(): void {
|
|
CacheService.getInstance().invalidate(TemplateService.CACHE_KEY);
|
|
console.log('[Templates] Cache invalidated');
|
|
}
|
|
|
|
public async getTemplates(): Promise<Template[]> {
|
|
try {
|
|
return await CacheService.getInstance().getOrFetch<Template[]>(
|
|
TemplateService.CACHE_KEY,
|
|
this.CACHE_DURATION_MS,
|
|
async () => {
|
|
const settings = DatabaseService.getInstance().getGlobalSettings();
|
|
// Default to a reliable LSIO Portainer v2 template registry if not set
|
|
const registryUrl = settings.template_registry_url || 'https://api.linuxserver.io/api/v1/images?include_config=true';
|
|
|
|
console.log(`[Templates] Fetching from registry: ${registryUrl}`);
|
|
const debug = isDebugEnabled();
|
|
|
|
let registryHost = '';
|
|
try { registryHost = new URL(registryUrl).hostname.toLowerCase(); } catch { /* invalid URL, treated as non-LSIO */ }
|
|
if (registryHost === 'api.linuxserver.io') {
|
|
const response = await axios.get<LsioApiResponse>(registryUrl, { timeout: 20_000 });
|
|
// Official LSIO API Schema Mapping
|
|
const lsioApps = response.data?.data?.repositories?.linuxserver ?? {};
|
|
|
|
const templates: Template[] = Object.values(lsioApps).map((app: LsioApp) => ({
|
|
type: 1,
|
|
title: app.name,
|
|
description: app.description || '',
|
|
logo: app.logo || `https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/img/${app.name}-logo.png`,
|
|
image: `lscr.io/linuxserver/${app.name}:latest`,
|
|
github_url: app.github,
|
|
docs_url: app.readme,
|
|
architectures: app.arch,
|
|
stars: app.stars,
|
|
categories: getCategoriesForApp(app.name),
|
|
source: 'linuxserver',
|
|
// Map configs if available, otherwise default to empty arrays
|
|
ports: (app.config?.ports ?? []).map((p: LsioPort) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`),
|
|
volumes: (app.config?.volumes ?? []).map((v: LsioVolume) => {
|
|
const folderName = v.path.split('/').filter(Boolean).pop() || 'data';
|
|
return {
|
|
container: v.path,
|
|
bind: `./${folderName}`
|
|
};
|
|
}),
|
|
env: (app.config?.environment ?? []).map((e: LsioEnvVar) => ({
|
|
name: e.name,
|
|
label: e.desc || e.name,
|
|
default: e.default || ''
|
|
}))
|
|
}));
|
|
|
|
console.log(`[Templates] Fetched ${templates.length} templates from LSIO`);
|
|
if (debug) console.debug('[Templates:debug] LSIO sample:', templates.slice(0, 5).map(t => t.title));
|
|
return templates;
|
|
}
|
|
|
|
// Legacy Portainer v2 Format (Fallback for custom registries)
|
|
// The Portainer v2 spec includes a native `categories` field; pass it through.
|
|
const response = await axios.get<TemplatesResponse>(registryUrl, { timeout: 20_000 });
|
|
const templates = (response.data.templates || [])
|
|
.filter((t: Template) => !!t.image && t.type === 1)
|
|
.map((t: Template) => ({ ...t, source: 'custom' }));
|
|
|
|
console.log(`[Templates] Fetched ${templates.length} templates from custom registry`);
|
|
return templates;
|
|
},
|
|
);
|
|
} catch (error) {
|
|
console.error('[Templates] Failed to fetch from registry:', error);
|
|
throw new Error('Could not fetch templates from registry');
|
|
}
|
|
}
|
|
|
|
public generateComposeFromTemplate(template: Template, serviceName: string): string {
|
|
let yaml = `services:\n ${serviceName}:\n`;
|
|
|
|
if (template.image) {
|
|
yaml += ` image: ${template.image}\n`;
|
|
}
|
|
|
|
yaml += ` restart: unless-stopped\n`;
|
|
|
|
if (template.ports && template.ports.length > 0) {
|
|
yaml += ` ports:\n`;
|
|
for (const port of template.ports) {
|
|
yaml += ` - "${port}"\n`;
|
|
}
|
|
}
|
|
|
|
if (template.volumes && template.volumes.length > 0) {
|
|
yaml += ` volumes:\n`;
|
|
for (const vol of template.volumes) {
|
|
let hostPath = '';
|
|
let containerPath = '';
|
|
let options = '';
|
|
|
|
if (typeof vol === 'string') {
|
|
const parts = vol.split(':');
|
|
if (parts.length === 1) {
|
|
yaml += ` - ${vol}\n`;
|
|
continue;
|
|
}
|
|
hostPath = parts[0];
|
|
containerPath = parts[1];
|
|
options = parts.slice(2).join(':');
|
|
if (options) options = `:${options}`;
|
|
} else if (vol.container) {
|
|
containerPath = vol.container;
|
|
const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data';
|
|
hostPath = vol.bind ? vol.bind : `./${containerFolder}`;
|
|
options = vol.readonly ? ':ro' : '';
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
|
|
yaml += ` - ${hostPath}:${containerPath}${options}\n`;
|
|
}
|
|
}
|
|
|
|
if (template.env && template.env.length > 0) {
|
|
yaml += ` env_file:\n - .env\n`;
|
|
}
|
|
|
|
return yaml;
|
|
}
|
|
|
|
public generateEnvString(envVars: Record<string, string>): string {
|
|
return Object.entries(envVars)
|
|
.map(([key, value]) => `${key}=${value}`)
|
|
.join('\n');
|
|
}
|
|
}
|
|
|
|
export const templateService = new TemplateService();
|