internal cleanups and build config
This commit is contained in:
10 files changed
+435
-22
No files matched your search
@@ -2,14 +2,28 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getSelfhstIcon, isValidSelfhstRef } from '$lib/server/selfhst-icons';
|
||||
|
||||
const SVG_HEADERS = {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
// The SVG is sanitized at cache time; these headers are belt-and-braces so even a
|
||||
// direct navigation to this URL cannot execute embedded content.
|
||||
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
} as const;
|
||||
|
||||
// A neutral placeholder returned (200) when an icon can't be resolved, instead of a
|
||||
// 404. A grid of icons where several 404 reads like probing to a WAF (CrowdSec) and
|
||||
// gets the client blocked (#1467); a 200 placeholder that the UI can still show avoids
|
||||
// that while looking the same to the user as the old fallback glyph.
|
||||
const PLACEHOLDER_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 12h8"/></svg>';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get a selfh.st app icon (raw SVG), fetched once from the CDN and cached on disk
|
||||
* description: Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches the SVG from jsdelivr and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). Returns 404 if the ref is invalid or the icon can't be fetched, so the UI falls back to a generic icon.
|
||||
* description: Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches the SVG from jsdelivr and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). When the icon can't be resolved it returns a neutral placeholder SVG (200) rather than a 404, so a grid of icons doesn't read as probing to a WAF.
|
||||
* path: ref:string! selfh.st icon Reference (lowercase letters, digits, hyphens), e.g. "plex"
|
||||
* resp-200: Raw image/svg+xml body, Cache-Control public max-age=604800
|
||||
* resp-200: Raw image/svg+xml body (the icon, or a neutral placeholder if it can't be resolved), Cache-Control public max-age=604800
|
||||
* resp-400: The ref is not a valid selfh.st reference
|
||||
* resp-404: No such icon (invalid ref or upstream fetch failed)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const ref = params.ref;
|
||||
@@ -18,16 +32,13 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
}
|
||||
const buf = await getSelfhstIcon(ref);
|
||||
if (!buf) {
|
||||
return json({ error: 'Icon not found' }, { status: 404 });
|
||||
// A short cache so a ref that resolves later (transient CDN failure) is retried
|
||||
// soon, but repeated views in the meantime don't re-hit the server.
|
||||
return new Response(PLACEHOLDER_SVG, {
|
||||
headers: { ...SVG_HEADERS, 'Cache-Control': 'public, max-age=300' }
|
||||
});
|
||||
}
|
||||
return new Response(new Uint8Array(buf), {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
// The SVG is sanitized at cache time; these headers are belt-and-braces so
|
||||
// even a direct navigation to this URL cannot execute embedded content.
|
||||
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
}
|
||||
headers: { ...SVG_HEADERS, 'Cache-Control': 'public, max-age=604800' }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getSelfhstIcon, sanitizeRefList } from '$lib/server/selfhst-icons';
|
||||
|
||||
// Cap how many refs one call resolves, and how many upstream fetches run at once,
|
||||
// so a single request can't fan out into an unbounded burst of CDN fetches.
|
||||
const MAX_REFS = 200;
|
||||
const CONCURRENCY = 8;
|
||||
|
||||
/**
|
||||
* POST /api/icons/selfhst/batch - resolve many selfh.st icons in ONE request.
|
||||
*
|
||||
* The icon picker renders a grid of app logos; fetching each as its own
|
||||
* <img src="/api/icons/selfhst/<ref>"> makes dozens of distinct requests in a
|
||||
* second, which a WAF (CrowdSec) flags as crawling/probing and blocks the client
|
||||
* (#1467). This batches them: the client asks for all visible refs at once and gets
|
||||
* one response, so the WAF sees a single request. Refs that can't be resolved are
|
||||
* simply omitted (no per-icon 404s, which also read as probing).
|
||||
*
|
||||
* Each icon is returned as a `data:image/svg+xml` URI so the picker keeps rendering
|
||||
* via <img> (script-inert) rather than inlining SVG markup - the SVG sanitizer was
|
||||
* designed for the <img>+CSP path, so we don't weaken it by switching to {@html}.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Resolve multiple selfh.st app icons in one request (avoids per-icon requests a WAF flags as crawling)
|
||||
* description: Takes a list of selfh.st icon references and returns each resolved icon as a data:image/svg+xml URI, keyed by reference. Unresolvable or invalid refs are omitted rather than returned as errors. Each icon is fetched from the CDN once and cached on disk, same as the single-icon endpoint.
|
||||
* body: {refs:array<string>!}
|
||||
* body-example: {"refs":["plex","gitea","grafana"]}
|
||||
* resp-200: {icons:object!}
|
||||
* resp-200-desc: icons maps each resolved reference to a data:image/svg+xml base64 URI; unresolvable refs are omitted.
|
||||
* resp-200-example: {"icons":{"plex":"data:image/svg+xml;base64,PHN2Zy4uLg=="}}
|
||||
* resp-400: The request body is missing a refs array
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
let body: { refs?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
if (!Array.isArray(body.refs)) {
|
||||
return json({ error: 'Body must include a refs array' }, { status: 400 });
|
||||
}
|
||||
|
||||
// De-dupe, validate, and cap before doing any work.
|
||||
const refs = sanitizeRefList(body.refs, MAX_REFS);
|
||||
|
||||
const icons: Record<string, string> = {};
|
||||
let next = 0;
|
||||
async function worker(): Promise<void> {
|
||||
while (next < refs.length) {
|
||||
const ref = refs[next++];
|
||||
const buf = await getSelfhstIcon(ref);
|
||||
// data: URI so the picker renders via <img> (script-inert), keeping the same
|
||||
// safety posture as the single-icon endpoint.
|
||||
if (buf) icons[ref] = `data:image/svg+xml;base64,${buf.toString('base64')}`;
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, refs.length) }, worker));
|
||||
|
||||
return json(
|
||||
{ icons },
|
||||
{
|
||||
headers: {
|
||||
// The picker re-opens often; let the browser cache the batch briefly so a
|
||||
// re-open of the same grid doesn't re-hit the server at all.
|
||||
'Cache-Control': 'private, max-age=300'
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { isSafeWebhookUrl } from '$lib/server/url-safety';
|
||||
import { getTemplateSources, type TemplateSource } from '$lib/server/templates';
|
||||
import { getAs93Slugs, resolveAs93Url } from '$lib/server/as93-slugs';
|
||||
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
@@ -25,6 +26,9 @@ export interface TemplateItem {
|
||||
note?: string;
|
||||
/** Project / homepage URL the user can click to learn more or report issues (#1211). */
|
||||
projectUrl?: string;
|
||||
/** Rich per-template page on portainer-templates.as93.net (Lissy93 catalog only,
|
||||
* and only when the slug is confirmed to exist - never a dead link) (#1211). */
|
||||
detailsUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +83,11 @@ function hashId(source: string, title: string): string {
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function normalizePortainerTemplate(entry: any, sourceName: string): TemplateItem | null {
|
||||
function normalizePortainerTemplate(
|
||||
entry: any,
|
||||
sourceName: string,
|
||||
as93Slugs?: Set<string>
|
||||
): TemplateItem | null {
|
||||
if (!entry.title && !entry.name) return null;
|
||||
// Skip Swarm templates (type 2)
|
||||
if (entry.type === 2) return null;
|
||||
@@ -96,7 +104,9 @@ function normalizePortainerTemplate(entry: any, sourceName: string): TemplateIte
|
||||
categories: Array.isArray(entry.categories) ? entry.categories : [],
|
||||
source: sourceName,
|
||||
note: note || undefined,
|
||||
projectUrl: resolveProjectUrl(entry, description, note)
|
||||
projectUrl: resolveProjectUrl(entry, description, note),
|
||||
// Lissy93 catalog only: a confirmed as93.net details page (else omitted).
|
||||
detailsUrl: (as93Slugs && resolveAs93Url(title, as93Slugs)) || undefined
|
||||
};
|
||||
|
||||
if (entry.type === 3 && entry.repository) {
|
||||
@@ -193,7 +203,16 @@ async function fetchSource(source: TemplateSource): Promise<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[];
|
||||
// Lissy93 catalog: load the as93.net slug set once so each template can get a
|
||||
// confirmed "Details" link (#1211). Fail-safe: any error yields an empty set,
|
||||
// so templates still load - they just get no Details link.
|
||||
const isLissy93 = /Lissy93\/portainer-templates/i.test(source.url);
|
||||
// getAs93Slugs never throws (returns an empty set on any failure), so a
|
||||
// sitemap outage just means no Details links - templates still load.
|
||||
const as93Slugs = isLissy93 ? await getAs93Slugs() : undefined;
|
||||
templates = entries
|
||||
.map((e: any) => normalizePortainerTemplate(e, source.name, as93Slugs))
|
||||
.filter(Boolean) as TemplateItem[];
|
||||
}
|
||||
|
||||
cache.set(source.url, { data: templates, fetchedAt: Date.now() });
|
||||
@@ -209,7 +228,7 @@ async function fetchSource(source: TemplateSource): Promise<TemplateItem[]> {
|
||||
*
|
||||
* @openapi
|
||||
* summary: Return the normalized app templates aggregated from all enabled template sources (server-side cached for 1 hour)
|
||||
* resp-200: array<{id:string!, type:string!, title:string!, description:string!, logo:string!, categories:array<string>!, source:string!, image:string, projectUrl:string}>
|
||||
* resp-200: array<{id:string!, type:string!, title:string!, description:string!, logo:string!, categories:array<string>!, source:string!, image:string, projectUrl:string, detailsUrl:string}>
|
||||
* resp-200-example: [{"id":"a1b2c3","type":"container","title":"Nginx","description":"Web server","logo":"https://example.com/nginx.png","categories":["web"],"source":"LinuxServer.io","image":"lscr.io/linuxserver/nginx:latest","projectUrl":"https://github.com/linuxserver/docker-nginx"}]
|
||||
* resp-403: Permission denied
|
||||
*/
|
||||
|
||||
@@ -69,6 +69,48 @@
|
||||
).slice(0, 120)
|
||||
);
|
||||
|
||||
// ref -> data: URI for the currently-shown grid. The icons are fetched in ONE
|
||||
// batch request instead of one <img> request per ref, so a WAF (CrowdSec) doesn't
|
||||
// see the grid as crawling and block the client (#1467).
|
||||
let iconData = $state<Record<string, string>>({});
|
||||
let iconsLoading = $state(false);
|
||||
let batchSeq = 0;
|
||||
// Refs already asked for (resolved OR omitted-as-unresolvable). We key `missing`
|
||||
// off THIS, not off iconData, so a ref the batch omits still counts as attempted
|
||||
// and never gets requested again - otherwise the effect (which reads+writes
|
||||
// iconData) would re-fire forever, POSTing the same unresolvable refs in a loop.
|
||||
// It is intentionally a plain Set (non-reactive), so mutating it doesn't retrigger.
|
||||
const attempted = new Set<string>();
|
||||
$effect(() => {
|
||||
const refs = selfhstResults.map((e) => e.Reference);
|
||||
if (refs.length === 0) return;
|
||||
const missing = refs.filter((r) => !attempted.has(r));
|
||||
if (missing.length === 0) return;
|
||||
for (const r of missing) attempted.add(r);
|
||||
const seq = ++batchSeq;
|
||||
iconsLoading = true;
|
||||
fetch('/api/icons/selfhst/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refs: missing })
|
||||
})
|
||||
.then((r) => (r.ok ? r.json() : { icons: {} }))
|
||||
.then((data: { icons?: Record<string, string> }) => {
|
||||
const icons = data.icons ?? {};
|
||||
if (Object.keys(icons).length > 0) iconData = { ...iconData, ...icons };
|
||||
})
|
||||
.catch(() => {
|
||||
/* leave unresolved refs as placeholder tiles */
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === batchSeq) iconsLoading = false;
|
||||
});
|
||||
});
|
||||
|
||||
// The first batch is still in flight and no icon has resolved yet -> show a spinner
|
||||
// instead of a grid of empty pulse tiles.
|
||||
const showIconSpinner = $derived(iconsLoading && Object.keys(iconData).length === 0);
|
||||
|
||||
function pickSelfhst(ref: string) {
|
||||
onselect(`selfhst:${ref}`);
|
||||
open = false;
|
||||
@@ -155,7 +197,12 @@
|
||||
<span class="text-center max-w-sm">{manifestError}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-12 gap-1 content-start">
|
||||
{#if showIconSpinner}
|
||||
<div class="flex items-center justify-center gap-2 py-12 text-muted-foreground text-sm">
|
||||
<Loader2 class="w-4 h-4 animate-spin" /> Loading icons...
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-12 gap-1 content-start" class:hidden={showIconSpinner}>
|
||||
{#each selfhstResults as entry (entry.Reference)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -163,7 +210,11 @@
|
||||
onclick={() => pickSelfhst(entry.Reference)}
|
||||
class="flex items-center justify-center aspect-square rounded-md border p-1 hover:bg-accent hover:border-primary transition-colors {value === `selfhst:${entry.Reference}` ? 'border-primary bg-accent' : 'border-transparent'}"
|
||||
>
|
||||
<img src="/api/icons/selfhst/{entry.Reference}" alt={entry.Name} loading="lazy" class="w-full h-full object-contain" />
|
||||
{#if iconData[entry.Reference]}
|
||||
<img src={iconData[entry.Reference]} alt={entry.Name} class="w-full h-full object-contain" />
|
||||
{:else}
|
||||
<div class="w-full h-full rounded bg-muted animate-pulse" aria-label={entry.Name}></div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Package, Star, Download, Loader2, ExternalLink } from 'lucide-svelte';
|
||||
import { Package, Star, Download, Loader2, ExternalLink, BookOpen } from 'lucide-svelte';
|
||||
import type { TemplateItem } from '../api/templates/+server';
|
||||
import { renderDescription } from '$lib/utils/template-description';
|
||||
|
||||
@@ -101,6 +101,23 @@
|
||||
Project
|
||||
</a>
|
||||
{/if}
|
||||
{#if template.detailsUrl}
|
||||
<a
|
||||
href={template.detailsUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center gap-0.5 hover:text-primary hover:underline"
|
||||
title="Open the detailed guide on portainer-templates.as93.net"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
window.open(template.detailsUrl, '_blank', 'noopener');
|
||||
}}
|
||||
>
|
||||
<BookOpen class="w-3 h-3" />
|
||||
Details
|
||||
</a>
|
||||
{/if}
|
||||
{#if template.stars}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<Star class="w-3 h-3" />
|
||||
|
||||
Reference in new issue
Block a user