Files
doktainer-upstream/src/server/services/backup/app-catalog.service.ts
T
2026-06-02 00:17:50 +07:00

1109 lines
29 KiB
TypeScript

import AdmZip from "adm-zip";
import yaml from "js-yaml";
import { APP_TEMPLATES, AppTemplate } from "../../config/app-templates";
export type CatalogSourceMode =
| "local"
| "auto-detect"
| "manifest-url"
| "github-archive";
export interface CatalogMeta {
source: CatalogSourceMode;
label: string;
url?: string;
fetchedAt: string;
isRemote: boolean;
cached?: boolean;
expiresAt?: string;
format?: string;
}
export interface CatalogSyncResult {
templates: AppTemplate[];
meta: CatalogMeta;
}
export interface CatalogSourceOption {
id: CatalogSourceMode;
label: string;
description: string;
requiresUrl: boolean;
placeholder?: string;
exampleUrl?: string;
}
interface CacheEntry {
templates: AppTemplate[];
meta: CatalogMeta;
expiresAt: number;
}
const REMOTE_CATALOG_CACHE = new Map<string, CacheEntry>();
const CACHE_TTL_MS = 5 * 60 * 1000;
export const CATALOG_SOURCE_OPTIONS: CatalogSourceOption[] = [
{
id: "local",
label: "Built-in Catalog",
description: "Use the local catalog bundled with this backend.",
requiresUrl: false,
},
{
id: "auto-detect",
label: "Custom Source",
description:
"Paste a remote URL and let the backend detect whether it is a manifest, compose/config file, or ZIP app store archive.",
requiresUrl: true,
placeholder:
"https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip",
exampleUrl: "https://casaos-appstore.paodayag.dev/linuxserver.zip",
},
{
id: "manifest-url",
label: "Manifest URL",
description:
"Fetch app entries from a remote JSON, YAML, CasaOS config, compose, or Umbrel manifest URL.",
requiresUrl: true,
placeholder: "https://example.com/apps.json",
exampleUrl:
"https://raw.githubusercontent.com/bigbeartechworld/big-bear-casaos/master/Apps/2fauth/config.json",
},
{
id: "github-archive",
label: "GitHub Archive ZIP",
description:
"Download a GitHub archive ZIP and scan CasaOS app folders, config.json, docker-compose.yml, JSON, or YAML manifests.",
requiresUrl: true,
placeholder:
"https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip",
exampleUrl:
"https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip",
},
];
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() : undefined;
}
function isAbsoluteHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value.trim());
}
function resolveRemoteAssetUrl(
value: unknown,
baseUrl?: string,
): string | undefined {
const raw = asString(value);
if (!raw) return undefined;
if (isAbsoluteHttpUrl(raw)) return raw;
if (!baseUrl) return undefined;
try {
return new URL(raw, baseUrl).toString();
} catch {
return undefined;
}
}
function asNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function toStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
if (typeof value === "string") {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
function buildImageWithVersion(image?: string, version?: string): string {
const imageValue = image?.trim() ?? "";
const versionValue = version?.trim() ?? "";
if (!imageValue) return "";
if (!versionValue) return imageValue;
const lastColon = imageValue.lastIndexOf(":");
const lastSlash = imageValue.lastIndexOf("/");
if (lastColon > lastSlash) {
return imageValue;
}
return `${imageValue}:${versionValue}`;
}
function normalizeTags(entry: Record<string, unknown>): string[] {
const explicit = toStringArray(
entry.tags ?? entry.keywords ?? entry.categories ?? entry.category,
);
return explicit.slice(0, 8);
}
function normalizePorts(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number") {
return `${value}:${value}`;
}
if (Array.isArray(value)) {
return value
.map((item) => {
if (typeof item === "string") return item.trim();
if (typeof item === "number") return `${item}:${item}`;
const record = asRecord(item);
if (!record) return "";
const host = String(
record.host ?? record.hostPort ?? record.public ?? record.port ?? "",
).trim();
const container = String(
record.container ??
record.containerPort ??
record.private ??
record.target ??
record.port ??
"",
).trim();
const protocol = String(record.protocol ?? "tcp")
.trim()
.toLowerCase();
if (!host || !container) return "";
return protocol === "udp"
? `${host}:${container}/udp`
: `${host}:${container}`;
})
.filter(Boolean)
.join(",");
}
const record = asRecord(value);
if (!record) return "";
return Object.entries(record)
.map(([container, host]) => {
const hostStr = String(host ?? "").trim();
const containerStr = container.trim();
return hostStr && containerStr ? `${hostStr}:${containerStr}` : "";
})
.filter(Boolean)
.join(",");
}
function normalizeEnv(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
return value
.map((item) => {
if (typeof item === "string") return item.trim();
const record = asRecord(item);
if (!record) return "";
const key = String(
record.key ?? record.name ?? record.label ?? "",
).trim();
const val = String(
record.value ?? record.default ?? record.defaultValue ?? "",
).trim();
return key ? `${key}=${val}` : "";
})
.filter(Boolean)
.join("\n");
}
const record = asRecord(value);
if (!record) return "";
return Object.entries(record)
.map(([key, val]) => `${key}=${String(val ?? "").trim()}`)
.filter(Boolean)
.join("\n");
}
function normalizeVolumes(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
return value
.map((item) => {
if (typeof item === "string") return item.trim();
const record = asRecord(item);
if (!record) return "";
const host = String(
record.host ?? record.source ?? record.src ?? record.bind ?? "",
).trim();
const container = String(
record.container ??
record.target ??
record.dst ??
record.destination ??
"",
).trim();
return host && container ? `${host}:${container}` : "";
})
.filter(Boolean)
.join(",");
}
const record = asRecord(value);
if (!record) return "";
return Object.entries(record)
.map(([container, host]) => {
const hostStr = String(host ?? "").trim();
return hostStr && container.trim()
? `${hostStr}:${container.trim()}`
: "";
})
.filter(Boolean)
.join(",");
}
function normalizePresetList(entry: Record<string, unknown>) {
const presetsRaw = Array.isArray(entry.presets) ? entry.presets : [];
return presetsRaw
.map((preset) => {
const record = asRecord(preset);
if (!record) return null;
const name =
asString(record.name) ??
asString(record.title) ??
asString(record.id) ??
"Preset";
const id = asString(record.id) ?? slugify(name);
return {
id,
name,
desc: asString(record.desc) ?? asString(record.description) ?? "",
defaultPort: normalizePorts(
record.defaultPort ?? record.ports ?? record.port_map,
),
defaultEnv: normalizeEnv(
record.defaultEnv ?? record.env ?? record.environment,
),
defaultVolumes: normalizeVolumes(
record.defaultVolumes ?? record.volumes ?? record.storage,
),
defaultCommand:
asString(record.defaultCommand) ?? asString(record.command),
defaultNetwork:
asString(record.defaultNetwork) ?? asString(record.network),
restartPolicy:
asString(record.restartPolicy) ?? asString(record.restart),
};
})
.filter((preset): preset is NonNullable<typeof preset> => Boolean(preset));
}
function normalizeIcon(
entry: Record<string, unknown>,
baseUrl?: string,
): string | undefined {
const iconBlock = asRecord(entry.icon);
const logoBlock = asRecord(entry.logo);
return (
resolveRemoteAssetUrl(entry.icon, baseUrl) ??
resolveRemoteAssetUrl(entry.iconUrl, baseUrl) ??
resolveRemoteAssetUrl(entry.icon_url, baseUrl) ??
resolveRemoteAssetUrl(entry.logo, baseUrl) ??
resolveRemoteAssetUrl(entry.logoUrl, baseUrl) ??
resolveRemoteAssetUrl(entry.logo_url, baseUrl) ??
resolveRemoteAssetUrl(entry.thumbnail, baseUrl) ??
resolveRemoteAssetUrl(entry.thumb, baseUrl) ??
resolveRemoteAssetUrl(entry.avatar, baseUrl) ??
resolveRemoteAssetUrl(iconBlock?.en_us, baseUrl) ??
resolveRemoteAssetUrl(logoBlock?.en_us, baseUrl)
);
}
function normalizeEntry(
entry: Record<string, unknown>,
index: number,
baseUrl?: string,
): AppTemplate | null {
const name =
asString(entry.name) ??
asString(entry.title) ??
asString(entry.app_name) ??
"";
const image =
asString(entry.image) ??
asString(entry.docker_image) ??
asString(entry.container_image) ??
asString(entry.repo) ??
buildImageWithVersion(asString(entry.image), asString(entry.version));
if (!name || !image) {
return null;
}
const id =
(asString(entry.id) ??
asString(entry.slug) ??
asString(entry.appid) ??
slugify(name)) ||
`remote-${index}`;
const category =
asString(entry.category) ??
asString(entry.type) ??
asString(entry.section) ??
"Remote";
const desc =
asString(entry.desc) ??
asString(entry.description) ??
asString(entry.short_desc) ??
asString(entry.subtitle) ??
"Remote app catalog entry";
const defaultPort = normalizePorts(
entry.defaultPort ??
entry.ports ??
entry.port_map ??
entry.portMap ??
entry.port,
);
const defaultEnv = normalizeEnv(
entry.defaultEnv ?? entry.env ?? entry.environment ?? entry.envs,
);
const defaultVolumes = normalizeVolumes(
entry.defaultVolumes ?? entry.volumes ?? entry.storage ?? entry.mounts,
);
const presets = normalizePresetList(entry);
return {
id,
name,
desc,
category,
icon: normalizeIcon(entry, baseUrl),
image,
defaultPort,
defaultEnv,
defaultVolumes: defaultVolumes || undefined,
defaultCommand: asString(entry.defaultCommand) ?? asString(entry.command),
defaultNetwork: asString(entry.defaultNetwork) ?? asString(entry.network),
restartPolicy:
asString(entry.restartPolicy) ??
asString(entry.restart) ??
asString(entry.restart_policy) ??
"unless-stopped",
popular: Boolean(entry.popular ?? entry.featured ?? false),
tags: normalizeTags(entry),
presets: presets.length > 0 ? presets : undefined,
};
}
function extractCandidateEntries(payload: unknown): Record<string, unknown>[] {
if (Array.isArray(payload)) {
return payload
.map((item) => asRecord(item))
.filter((item): item is Record<string, unknown> => Boolean(item));
}
const record = asRecord(payload);
if (!record) return [];
const collectionKeys = [
"apps",
"templates",
"data",
"items",
"catalog",
"entries",
];
for (const key of collectionKeys) {
const value = record[key];
if (Array.isArray(value)) {
return value
.map((item) => asRecord(item))
.filter((item): item is Record<string, unknown> => Boolean(item));
}
}
return [record];
}
function dedupeTemplates(templates: AppTemplate[]): AppTemplate[] {
const seen = new Set<string>();
return templates.filter((template) => {
if (seen.has(template.id)) return false;
seen.add(template.id);
return true;
});
}
function normalizeGithubManifestUrl(url: string): string {
const parsed = new URL(url);
if (parsed.hostname === "github.com" && parsed.pathname.includes("/blob/")) {
const parts = parsed.pathname.split("/").filter(Boolean);
const owner = parts[0];
const repo = parts[1];
const branch = parts[3];
const filePath = parts.slice(4).join("/");
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`;
}
return url;
}
function normalizeGithubArchiveUrl(url: string): string {
const parsed = new URL(url);
if (parsed.hostname === "github.com") {
const match = parsed.pathname.match(
/^\/([^/]+)\/([^/]+)\/archive\/refs\/(heads|tags)\/([^/]+)\.zip$/,
);
if (match) {
const [, owner, repo, refType, refName] = match;
return `https://codeload.github.com/${owner}/${repo}/zip/refs/${refType}/${refName}`;
}
}
return url;
}
function getFallbackIdFromUrl(url: string): string {
try {
const parsed = new URL(url);
const segments = parsed.pathname.split("/").filter(Boolean);
const lastSegment = segments.at(-1) ?? "remote-manifest";
const normalizedLast = lastSegment.toLowerCase();
if (
/^(config\.json|docker-compose\.ya?ml|compose\.ya?ml|umbrel-app\.ya?ml|app\.json|manifest\.json|manifest\.ya?ml)$/.test(
normalizedLast,
)
) {
return slugify(segments.at(-2) ?? lastSegment);
}
return slugify(lastSegment.replace(/\.(json|ya?ml)$/i, ""));
} catch {
return slugify(url.split("/").pop() ?? "remote-manifest");
}
}
function isZipUrl(url: string): boolean {
try {
const parsed = new URL(url);
return /\.zip$/i.test(parsed.pathname);
} catch {
return /\.zip($|\?)/i.test(url);
}
}
function isZipContentType(contentType?: string | null): boolean {
if (!contentType) return false;
const normalized = contentType.toLowerCase();
return (
normalized.includes("zip") ||
normalized.includes("compressed") ||
normalized.includes("x-zip")
);
}
function getContentDispositionFileName(header?: string | null): string {
if (!header) return "";
const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch {
return utf8Match[1];
}
}
const plainMatch = header.match(/filename="?([^";]+)"?/i);
return plainMatch?.[1] ?? "";
}
function isZipResponse(response: Response, url: string): boolean {
const contentType = response.headers.get("content-type");
const fileName = getContentDispositionFileName(
response.headers.get("content-disposition"),
);
return (
isZipUrl(url) || isZipContentType(contentType) || /\.zip$/i.test(fileName)
);
}
function getCacheKey(source: CatalogSourceMode, url?: string): string {
return `${source}:${(url ?? "").trim()}`;
}
function getCachedCatalog(key: string): CacheEntry | undefined {
const entry = REMOTE_CATALOG_CACHE.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
REMOTE_CATALOG_CACHE.delete(key);
return undefined;
}
return entry;
}
function setCachedCatalog(
key: string,
templates: AppTemplate[],
meta: CatalogMeta,
): CatalogSyncResult {
const expiresAt = Date.now() + CACHE_TTL_MS;
const cacheMeta: CatalogMeta = {
...meta,
cached: false,
expiresAt: new Date(expiresAt).toISOString(),
};
REMOTE_CATALOG_CACHE.set(key, {
templates,
meta: cacheMeta,
expiresAt,
});
return { templates, meta: cacheMeta };
}
function makeCachedResult(entry: CacheEntry): CatalogSyncResult {
return {
templates: entry.templates,
meta: {
...entry.meta,
cached: true,
expiresAt: new Date(entry.expiresAt).toISOString(),
},
};
}
function parseStructuredText(text: string): unknown {
const trimmed = text.trim();
if (!trimmed) return {};
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return JSON.parse(trimmed);
}
return yaml.load(trimmed);
}
function toEnvStringFromCompose(value: unknown): string {
return normalizeEnv(value);
}
function toVolumeStringFromCompose(value: unknown): string {
return normalizeVolumes(value);
}
function getMainService(
composeRecord: Record<string, unknown>,
): Record<string, unknown> | undefined {
const services = asRecord(composeRecord.services);
if (!services) return undefined;
const casaOs = asRecord(composeRecord["x-casaos"]);
const mainName = asString(casaOs?.main);
if (mainName) {
const service = asRecord(services[mainName]);
if (service) return service;
}
const firstKey = Object.keys(services)[0];
return firstKey ? asRecord(services[firstKey]) : undefined;
}
function parseCasaOsCompose(
composeRecord: Record<string, unknown>,
fallbackId: string,
baseUrl?: string,
): AppTemplate | null {
const casaOs = asRecord(composeRecord["x-casaos"]);
const mainService = getMainService(composeRecord);
const titleBlock = asRecord(casaOs?.title);
const descBlock = asRecord(casaOs?.description);
const taglineBlock = asRecord(casaOs?.tagline);
const name =
asString(titleBlock?.en_us) ??
asString(casaOs?.title) ??
asString(casaOs?.name) ??
fallbackId;
const description =
asString(descBlock?.en_us) ??
asString(taglineBlock?.en_us) ??
asString(casaOs?.description) ??
"Community app compose manifest";
const image =
asString(mainService?.image) ??
buildImageWithVersion(asString(casaOs?.image), asString(casaOs?.version));
if (!image) return null;
return {
id: asString(casaOs?.id) ?? slugify(fallbackId),
name,
desc: description,
category: asString(casaOs?.category) ?? "Community",
icon: normalizeIcon({ icon: casaOs?.icon, logo: casaOs?.logo }, baseUrl),
image,
defaultPort: normalizePorts(casaOs?.port_map ?? mainService?.ports),
defaultEnv: toEnvStringFromCompose(mainService?.environment),
defaultVolumes: (() => {
const volumes = toVolumeStringFromCompose(mainService?.volumes);
return volumes || undefined;
})(),
defaultCommand: asString(mainService?.command),
defaultNetwork:
asString(mainService?.network_mode) ?? asString(casaOs?.network),
restartPolicy:
asString(mainService?.restart) ??
asString(casaOs?.restart_policy) ??
"unless-stopped",
popular: false,
tags: [
...new Set(
[
asString(casaOs?.category),
"community",
...toStringArray(casaOs?.architectures),
].filter((tag): tag is string => Boolean(tag)),
),
],
};
}
function parseCasaOsConfigJson(
record: Record<string, unknown>,
fallbackId: string,
baseUrl?: string,
): AppTemplate | null {
const id = asString(record.id) ?? slugify(fallbackId);
const name =
asString(record.name) ??
asString(asRecord(record.title)?.en_us) ??
asString(record.id) ??
fallbackId;
const image = buildImageWithVersion(
asString(record.image),
asString(record.version),
);
if (!image) return null;
return {
id,
name,
icon: normalizeIcon(record, baseUrl),
desc:
asString(record.description) ??
asString(record.short_desc) ??
"Community app config",
category: toStringArray(record.categories)[0] ?? "Community",
image,
defaultPort: normalizePorts(record.port),
defaultEnv: normalizeEnv(record.form_fields),
restartPolicy: "unless-stopped",
popular: Boolean(record.featured ?? false),
tags: [...new Set([...toStringArray(record.categories), "community"])],
};
}
function parseUmbrelManifest(
record: Record<string, unknown>,
fallbackId: string,
baseUrl?: string,
): AppTemplate | null {
const name = asString(record.name) ?? fallbackId;
const id = asString(record.id) ?? slugify(fallbackId);
const port = asNumber(record.port);
const image =
asString(record.image) ??
buildImageWithVersion(asString(record.repo), asString(record.version));
if (!name || !image) return null;
return {
id,
name,
icon: normalizeIcon(record, baseUrl),
desc:
asString(record.description) ??
asString(record.tagline) ??
"Umbrel manifest",
category: asString(record.category) ?? "Umbrel",
image,
defaultPort: port ? `${port}:${port}` : normalizePorts(record.port),
defaultEnv: "",
defaultVolumes: undefined,
defaultCommand: undefined,
defaultNetwork: undefined,
restartPolicy: "unless-stopped",
popular: false,
tags: [
...new Set(["umbrel", asString(record.category) ?? ""].filter(Boolean)),
],
};
}
function normalizeSingleManifest(
payload: unknown,
fallbackId: string,
baseUrl?: string,
): AppTemplate[] {
const record = asRecord(payload);
if (!record) return [];
const compose = parseCasaOsCompose(record, fallbackId, baseUrl);
if (compose) return [compose];
const casaConfig = parseCasaOsConfigJson(record, fallbackId, baseUrl);
if (casaConfig) return [casaConfig];
const umbrel = parseUmbrelManifest(record, fallbackId, baseUrl);
if (umbrel) return [umbrel];
const genericEntries = extractCandidateEntries(payload)
.map((entry, index) => normalizeEntry(entry, index, baseUrl))
.filter((entry): entry is AppTemplate => Boolean(entry));
return genericEntries;
}
function getAppDirKey(entryName: string): string | undefined {
const match = entryName.match(/(^|\/)Apps\/([^/]+)\//i);
return match?.[2]?.toLowerCase();
}
function parseCasaOsArchiveCatalog(zip: AdmZip): AppTemplate[] {
const configEntries = new Map<string, string>();
const composeEntries = new Map<string, string>();
const results: AppTemplate[] = [];
zip.getEntries().forEach((entry) => {
if (entry.isDirectory) return;
const appDirKey = getAppDirKey(entry.entryName);
if (!appDirKey) return;
const lower = entry.entryName.toLowerCase();
if (lower.endsWith("/config.json")) {
configEntries.set(appDirKey, entry.entryName);
}
if (
lower.endsWith("/docker-compose.yml") ||
lower.endsWith("/docker-compose.yaml")
) {
composeEntries.set(appDirKey, entry.entryName);
}
});
Array.from(
new Set([...configEntries.keys(), ...composeEntries.keys()]),
).forEach((appDirKey) => {
const configPath = configEntries.get(appDirKey);
const composePath = composeEntries.get(appDirKey);
try {
const configPayload = configPath
? JSON.parse(zip.readAsText(configPath))
: undefined;
const composePayload = composePath
? parseStructuredText(zip.readAsText(composePath))
: undefined;
let template: AppTemplate | null = null;
if (composePayload) {
template = parseCasaOsCompose(
asRecord(composePayload) ?? {},
appDirKey,
);
}
if (!template && configPayload) {
template = parseCasaOsConfigJson(
asRecord(configPayload) ?? {},
appDirKey,
);
}
if (template) {
if (configPayload && !template.defaultPort) {
template.defaultPort = normalizePorts(asRecord(configPayload)?.port);
}
if (configPayload && template.tags.length === 0) {
template.tags = toStringArray(asRecord(configPayload)?.categories);
}
results.push(template);
}
} catch {
// Ignore malformed app folder data.
}
});
return results;
}
function parseGenericArchiveCatalog(zip: AdmZip): AppTemplate[] {
const templates: AppTemplate[] = [];
const candidateEntries = zip
.getEntries()
.filter((entry) => {
if (entry.isDirectory) return false;
const lower = entry.entryName.toLowerCase();
if (!/\.(json|ya?ml)$/.test(lower)) return false;
return /(app|catalog|manifest|template|store|config|compose|umbrel)/.test(
lower,
);
})
.slice(0, 300);
candidateEntries.forEach((entry) => {
try {
const payload = parseStructuredText(entry.getData().toString("utf8"));
const fallbackId = slugify(
entry.entryName.split("/").slice(-2, -1)[0] ?? entry.name,
);
const parsed = normalizeSingleManifest(payload, fallbackId);
templates.push(...parsed);
} catch {
// Ignore malformed files.
}
});
return templates;
}
async function fetchRemoteResponse(url: string): Promise<Response> {
const response = await fetch(url, {
headers: {
Accept:
"application/json, application/yaml, text/yaml, text/x-yaml, text/plain, application/octet-stream, */*",
"User-Agent": "vps-panel-app-catalog/1.0",
},
redirect: "follow",
});
if (!response.ok) {
throw new Error(
`Remote request failed with status ${response.status} for ${response.url}`,
);
}
return response;
}
async function parseArchiveResponse(
response: Response,
source: CatalogSourceMode,
label: string,
url: string,
): Promise<CatalogSyncResult> {
const buffer = Buffer.from(await response.arrayBuffer());
const zip = new AdmZip(buffer);
const casaOsTemplates = parseCasaOsArchiveCatalog(zip);
const genericTemplates = parseGenericArchiveCatalog(zip);
const templates = dedupeTemplates([...casaOsTemplates, ...genericTemplates]);
return {
templates,
meta: {
source,
label,
url,
fetchedAt: new Date().toISOString(),
isRemote: true,
format: casaOsTemplates.length > 0 ? "casaos-archive" : "archive",
},
};
}
async function loadManifestUrlCatalog(url: string): Promise<CatalogSyncResult> {
const normalizedUrl = normalizeGithubArchiveUrl(
normalizeGithubManifestUrl(url),
);
const response = await fetchRemoteResponse(normalizedUrl);
if (isZipResponse(response, normalizedUrl)) {
return parseArchiveResponse(
response,
"manifest-url",
"Remote URL Catalog",
normalizedUrl,
);
}
const text = await response.text();
const payload = parseStructuredText(text);
const fallbackId = getFallbackIdFromUrl(normalizedUrl);
const templates = dedupeTemplates(
normalizeSingleManifest(payload, fallbackId, normalizedUrl),
);
return {
templates,
meta: {
source: "manifest-url",
label: "Remote Manifest URL",
url: normalizedUrl,
fetchedAt: new Date().toISOString(),
isRemote: true,
format:
text.trim().startsWith("{") || text.trim().startsWith("[")
? "json"
: "yaml",
},
};
}
async function loadAutoDetectCatalog(url: string): Promise<CatalogSyncResult> {
const normalizedUrl = normalizeGithubArchiveUrl(
normalizeGithubManifestUrl(url),
);
const response = await fetchRemoteResponse(normalizedUrl);
if (isZipResponse(response, normalizedUrl)) {
return parseArchiveResponse(
response,
"auto-detect",
"Custom Source",
normalizedUrl,
);
}
const text = await response.text();
const payload = parseStructuredText(text);
const fallbackId = getFallbackIdFromUrl(normalizedUrl);
const templates = dedupeTemplates(
normalizeSingleManifest(payload, fallbackId, normalizedUrl),
);
return {
templates,
meta: {
source: "auto-detect",
label: "Custom Source",
url: normalizedUrl,
fetchedAt: new Date().toISOString(),
isRemote: true,
format:
text.trim().startsWith("{") || text.trim().startsWith("[")
? "json"
: "yaml",
},
};
}
async function loadGithubArchiveCatalog(
url: string,
): Promise<CatalogSyncResult> {
const normalizedUrl = normalizeGithubArchiveUrl(url);
const response = await fetchRemoteResponse(normalizedUrl);
return parseArchiveResponse(
response,
"github-archive",
"GitHub Archive ZIP",
normalizedUrl,
);
}
export async function getCatalogTemplates(
source: CatalogSourceMode,
url?: string,
): Promise<CatalogSyncResult> {
const fetchedAt = new Date().toISOString();
if (source === "local") {
return {
templates: APP_TEMPLATES,
meta: {
source,
label: "Built-in Catalog",
fetchedAt,
isRemote: false,
cached: false,
},
};
}
if (!url?.trim()) {
throw new Error("Remote catalog URL is required");
}
const trimmedUrl = url.trim();
const cacheKey = getCacheKey(source, trimmedUrl);
const cachedEntry = getCachedCatalog(cacheKey);
if (cachedEntry) {
return makeCachedResult(cachedEntry);
}
const result =
source === "auto-detect"
? await loadAutoDetectCatalog(trimmedUrl)
: source === "manifest-url"
? await loadManifestUrlCatalog(trimmedUrl)
: await loadGithubArchiveCatalog(trimmedUrl);
if (result.templates.length === 0) {
throw new Error(
"No app templates could be extracted from the remote source",
);
}
return setCachedCatalog(cacheKey, result.templates, result.meta);
}