From dc6a624ef941e738420068ce17076753234677ab Mon Sep 17 00:00:00 2001 From: NewtTheWolf Date: Mon, 3 Aug 2026 19:28:52 +0200 Subject: [PATCH 1/2] feat(instance): admin-configurable external docs URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs.external_url setting (admin instance settings, General tab): when set, the Docs links in the header and footer point there (e.g. the product website) instead of the built-in /docs pages. Exposed publicly via /api/instance/info, which the frontend now refreshes at boot. Empty or null clears the override; only http(s) URLs are accepted. Built-in /docs routes stay reachable by URL — the website embeds the live-generated plugin dev reference from /api/docs/plugin-development ?format=md, so deep links keep working. --- .../src/routes/api/admin/instance/index.ts | 14 +++++ apps/api/src/routes/api/instance/info.ts | 5 ++ .../routes/admin-instance-docs-url.test.ts | 52 +++++++++++++++++++ apps/frontend/messages/de.json | 2 + apps/frontend/messages/en.json | 2 + apps/frontend/messages/es.json | 2 + apps/frontend/messages/fr.json | 2 + apps/frontend/messages/it.json | 2 + apps/frontend/messages/zh-CN.json | 2 + .../frontend/src/lib/components/Footer.svelte | 6 ++- .../frontend/src/lib/components/Header.svelte | 3 +- .../src/lib/stores/instance-info.svelte.ts | 7 +++ apps/frontend/src/routes/+layout.svelte | 2 + .../routes/admin/instance/tabs/General.svelte | 43 ++++++++++++--- 14 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 apps/api/tests/routes/admin-instance-docs-url.test.ts diff --git a/apps/api/src/routes/api/admin/instance/index.ts b/apps/api/src/routes/api/admin/instance/index.ts index 288c1fb..aa36d03 100644 --- a/apps/api/src/routes/api/admin/instance/index.ts +++ b/apps/api/src/routes/api/admin/instance/index.ts @@ -72,6 +72,7 @@ export default new Elysia() '/', async () => ({ requireApproval: getSetting('instance.require_approval') === '1', + docsExternalUrl: getSetting('docs.external_url') ?? null, rateLimits: await bucketStates(), manifest: manifestState(), assetSizeCapBytes: getAssetSizeCap(), @@ -86,6 +87,7 @@ export default new Elysia() response: { 200: t.Object({ requireApproval: t.Boolean(), + docsExternalUrl: t.Nullable(t.String()), rateLimits: t.Array( t.Object({ id: t.String(), @@ -116,6 +118,17 @@ export default new Elysia() if (body.requireApproval !== undefined) { await setSetting('instance.require_approval', body.requireApproval ? '1' : '0') } + if (body.docsExternalUrl !== undefined) { + if (body.docsExternalUrl === null || body.docsExternalUrl === '') { + if (hasSetting('docs.external_url')) await deleteSetting('docs.external_url') + } else { + if (!/^https?:\/\/.+/.test(body.docsExternalUrl)) { + set.status = 400 + return { error: 'docsExternalUrl must be an http(s) URL' } + } + await setSetting('docs.external_url', body.docsExternalUrl) + } + } if (body.rateLimits) { const valid: Set = new Set(BUCKETS.map((b) => b.id)) for (const r of body.rateLimits) { @@ -211,6 +224,7 @@ export default new Elysia() }, body: t.Object({ requireApproval: t.Optional(t.Boolean()), + docsExternalUrl: t.Optional(t.Nullable(t.String({ maxLength: 500 }))), rateLimits: t.Optional( t.Array( t.Object({ diff --git a/apps/api/src/routes/api/instance/info.ts b/apps/api/src/routes/api/instance/info.ts index 424fea5..a504c62 100644 --- a/apps/api/src/routes/api/instance/info.ts +++ b/apps/api/src/routes/api/instance/info.ts @@ -1,5 +1,6 @@ import { Elysia, t } from 'elysia' import { getAppUrlSchemes } from '$lib/app-schemes' +import { getSetting } from '$lib/settings' const appUrlSchemeSchema = t.Object({ name: t.String(), @@ -11,6 +12,9 @@ export default new Elysia().get( '/', () => ({ appUrlSchemes: getAppUrlSchemes(), + // When set, the frontend links "Docs" here (e.g. the product website) + // instead of the built-in /docs pages. + docsExternalUrl: getSetting('docs.external_url') ?? null, }), { detail: { @@ -23,6 +27,7 @@ export default new Elysia().get( response: { 200: t.Object({ appUrlSchemes: t.Array(appUrlSchemeSchema), + docsExternalUrl: t.Nullable(t.String()), }), }, }, diff --git a/apps/api/tests/routes/admin-instance-docs-url.test.ts b/apps/api/tests/routes/admin-instance-docs-url.test.ts new file mode 100644 index 0000000..74a0d64 --- /dev/null +++ b/apps/api/tests/routes/admin-instance-docs-url.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from 'bun:test' +import { buildApp, clearDb, makeUser } from '../helpers' +import { signJwt } from '../../src/lib/jwt' +import { getSetting } from '../../src/lib/settings' + +async function adminCookie() { + const admin = await makeUser({ role: 'admin', username: 'admin' }) + const jwt = await signJwt({ + sub: admin.id, + identityId: admin.identityId, + username: admin.username, + providerInstanceId: admin.providerInstanceId, + }) + return { Cookie: `auth=${jwt}` } +} + +async function putInstance(body: Record) { + const app = await buildApp() + return app.handle( + new Request('http://localhost/api/admin/instance/', { + method: 'PUT', + headers: { ...(await adminCookie()), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ) +} + +describe('docs.external_url setting', () => { + beforeEach(clearDb) + + it('sets, exposes publicly, and clears the external docs URL', async () => { + const app = await buildApp() + + const set = await putInstance({ docsExternalUrl: 'https://tabularis.dev/wiki/plugin-development' }) + expect(set.status).toBe(200) + expect(getSetting('docs.external_url')).toBe('https://tabularis.dev/wiki/plugin-development') + + const info = await app.handle(new Request('http://localhost/api/instance/info/')) + const body = (await info.json()) as { docsExternalUrl: string | null } + expect(body.docsExternalUrl).toBe('https://tabularis.dev/wiki/plugin-development') + + const clear = await putInstance({ docsExternalUrl: null }) + expect(clear.status).toBe(200) + expect(getSetting('docs.external_url')).toBeUndefined() + }) + + it('rejects non-http(s) URLs', async () => { + const res = await putInstance({ docsExternalUrl: 'javascript:alert(1)' }) + expect(res.status).toBe(400) + expect(getSetting('docs.external_url')).toBeUndefined() + }) +}) diff --git a/apps/frontend/messages/de.json b/apps/frontend/messages/de.json index cf27d3a..e77ae74 100644 --- a/apps/frontend/messages/de.json +++ b/apps/frontend/messages/de.json @@ -182,6 +182,8 @@ "admin_instance_rate_limits": "Rate-Limits", "admin_instance_rate_limits_subtitle": "Limit pro Bucket (max. Requests) innerhalb des Fensters (Sekunden). Subjekt = authentifizierter Nutzer, wenn vorhanden, sonst Client-IP.", "admin_instance_require_approval": "Admin-Freigabe für neue Plugins erforderlich", + "admin_instance_docs_url_title": "Externe Docs-URL", + "admin_instance_docs_url_subtitle": "Wenn gesetzt, zeigen die Docs-Links in Header und Footer auf diese URL (z. B. die Produkt-Website) statt auf die eingebauten Docs-Seiten. Leer lassen, um die eingebauten Docs zu behalten.", "admin_instance_reset_failed": "Zurücksetzen fehlgeschlagen", "admin_instance_reset_to_default": "{id} auf Standard zurückgesetzt", "admin_instance_save_failed": "Speichern fehlgeschlagen", diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index e8baec4..e010bdc 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -182,6 +182,8 @@ "admin_instance_rate_limits": "Rate limits", "admin_instance_rate_limits_subtitle": "Per-bucket limit (max requests) within the window (seconds). Subject = authenticated user when present, otherwise client IP.", "admin_instance_require_approval": "Require admin approval for new plugins", + "admin_instance_docs_url_title": "External docs URL", + "admin_instance_docs_url_subtitle": "When set, the Docs links in the header and footer point to this URL (e.g. your product website) instead of the built-in docs pages. Leave empty to keep the built-in docs.", "admin_instance_reset_failed": "Failed to reset", "admin_instance_reset_to_default": "{id} reset to default", "admin_instance_save_failed": "Failed to save", diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index e19d511..3170427 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -182,6 +182,8 @@ "admin_instance_rate_limits": "Rate limits", "admin_instance_rate_limits_subtitle": "Límite por bucket (máx. peticiones) dentro de la ventana (segundos). Sujeto = usuario autenticado si existe, si no IP del cliente.", "admin_instance_require_approval": "Requerir aprobación de admin para plugins nuevos", + "admin_instance_docs_url_title": "URL de documentación externa", + "admin_instance_docs_url_subtitle": "Si se establece, los enlaces de documentación del encabezado y pie de página apuntan a esta URL (p. ej., el sitio web del producto) en lugar de las páginas integradas. Déjalo vacío para mantener la documentación integrada.", "admin_instance_reset_failed": "Fallo al restablecer", "admin_instance_reset_to_default": "{id} restablecido a por defecto", "admin_instance_save_failed": "Fallo al guardar", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 2c60b2d..3c0b3cf 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -182,6 +182,8 @@ "admin_instance_rate_limits": "Rate limits", "admin_instance_rate_limits_subtitle": "Limite par bucket (max requêtes) dans la fenêtre (secondes). Sujet = utilisateur authentifié si présent, sinon IP client.", "admin_instance_require_approval": "Exiger l'approbation admin pour les nouveaux plugins", + "admin_instance_docs_url_title": "URL de documentation externe", + "admin_instance_docs_url_subtitle": "Si défini, les liens Docs de l'en-tête et du pied de page pointent vers cette URL (p. ex. le site web du produit) au lieu des pages intégrées. Laissez vide pour conserver la documentation intégrée.", "admin_instance_reset_failed": "Échec de la réinitialisation", "admin_instance_reset_to_default": "{id} réinitialisé au défaut", "admin_instance_save_failed": "Échec de l'enregistrement", diff --git a/apps/frontend/messages/it.json b/apps/frontend/messages/it.json index a93678a..0695fc4 100644 --- a/apps/frontend/messages/it.json +++ b/apps/frontend/messages/it.json @@ -182,6 +182,8 @@ "admin_instance_rate_limits": "Rate limit", "admin_instance_rate_limits_subtitle": "Limite per bucket (max richieste) nella finestra (secondi). Soggetto = utente autenticato se presente, altrimenti IP client.", "admin_instance_require_approval": "Richiedi approvazione admin per i nuovi plugin", + "admin_instance_docs_url_title": "URL documentazione esterna", + "admin_instance_docs_url_subtitle": "Se impostato, i link Docs nell'header e nel footer puntano a questo URL (ad es. il sito web del prodotto) invece delle pagine integrate. Lascia vuoto per mantenere la documentazione integrata.", "admin_instance_reset_failed": "Reimpostazione fallita", "admin_instance_reset_to_default": "{id} reimpostato al default", "admin_instance_save_failed": "Salvataggio fallito", diff --git a/apps/frontend/messages/zh-CN.json b/apps/frontend/messages/zh-CN.json index 1fadd6a..291f006 100644 --- a/apps/frontend/messages/zh-CN.json +++ b/apps/frontend/messages/zh-CN.json @@ -182,6 +182,8 @@ "admin_instance_rate_limits": "限流", "admin_instance_rate_limits_subtitle": "每个 bucket 在窗口(秒)内的请求上限。Subject = 已认证用户(若存在),否则为客户端 IP。", "admin_instance_require_approval": "新插件需要管理员审批", + "admin_instance_docs_url_title": "外部文档 URL", + "admin_instance_docs_url_subtitle": "设置后,页眉和页脚中的文档链接将指向此 URL(例如产品网站),而不是内置文档页面。留空则继续使用内置文档。", "admin_instance_reset_failed": "重置失败", "admin_instance_reset_to_default": "{id} 已重置为默认值", "admin_instance_save_failed": "保存失败", diff --git a/apps/frontend/src/lib/components/Footer.svelte b/apps/frontend/src/lib/components/Footer.svelte index bf055b4..b7f5959 100644 --- a/apps/frontend/src/lib/components/Footer.svelte +++ b/apps/frontend/src/lib/components/Footer.svelte @@ -5,6 +5,7 @@ import { branding } from '$lib/stores/branding.svelte' import { features } from '$lib/stores/features.svelte' import { i18n } from '$lib/stores/i18n.svelte' + import { instanceInfo } from '$lib/stores/instance-info.svelte' import { m } from '$lib/paraglide/messages' import type { PageSummary } from '$lib/types' @@ -105,7 +106,10 @@
{m.footer_developers()}
- + {m.docs_plugin_dev_title()} l.show), diff --git a/apps/frontend/src/lib/stores/instance-info.svelte.ts b/apps/frontend/src/lib/stores/instance-info.svelte.ts index fa7bb78..42d7911 100644 --- a/apps/frontend/src/lib/stores/instance-info.svelte.ts +++ b/apps/frontend/src/lib/stores/instance-info.svelte.ts @@ -8,10 +8,12 @@ export type AppUrlScheme = { export type InstanceInfo = { appUrlSchemes: AppUrlScheme[] + docsExternalUrl: string | null } const DEFAULTS: InstanceInfo = { appUrlSchemes: [], + docsExternalUrl: null, } function createInstanceInfoStore() { @@ -24,8 +26,10 @@ function createInstanceInfoStore() { if (error) throw error const i = data as InstanceInfo state.appUrlSchemes = i.appUrlSchemes ?? [] + state.docsExternalUrl = i.docsExternalUrl ?? null } catch { state.appUrlSchemes = DEFAULTS.appUrlSchemes + state.docsExternalUrl = DEFAULTS.docsExternalUrl } finally { loaded = true } @@ -48,6 +52,9 @@ function createInstanceInfoStore() { get appUrlSchemes() { return state.appUrlSchemes }, + get docsExternalUrl() { + return state.docsExternalUrl + }, get loaded() { return loaded }, diff --git a/apps/frontend/src/routes/+layout.svelte b/apps/frontend/src/routes/+layout.svelte index 7b3c5ae..8209435 100644 --- a/apps/frontend/src/routes/+layout.svelte +++ b/apps/frontend/src/routes/+layout.svelte @@ -8,6 +8,7 @@ import { auth } from '$lib/stores/auth.svelte' import { branding } from '$lib/stores/branding.svelte' import { features } from '$lib/stores/features.svelte' + import { instanceInfo } from '$lib/stores/instance-info.svelte' import { homeCopy } from '$lib/stores/home-copy.svelte' import { i18n } from '$lib/stores/i18n.svelte' @@ -19,6 +20,7 @@ branding.refresh() features.refresh() homeCopy.refresh() + instanceInfo.refresh() }) diff --git a/apps/frontend/src/routes/admin/instance/tabs/General.svelte b/apps/frontend/src/routes/admin/instance/tabs/General.svelte index e785b84..51a0a85 100644 --- a/apps/frontend/src/routes/admin/instance/tabs/General.svelte +++ b/apps/frontend/src/routes/admin/instance/tabs/General.svelte @@ -7,16 +7,20 @@ import CardHeader from '$components/ui/CardHeader.svelte' import CardTitle from '$components/ui/CardTitle.svelte' import Badge from '$components/ui/Badge.svelte' + import Input from '$components/ui/Input.svelte' import StickySaveBar from '$components/admin/StickySaveBar.svelte' import { eden } from '$lib/eden' import { m } from '$lib/paraglide/messages' let requireApproval = $state(false) - let initial = $state(false) + let docsExternalUrl = $state('') + let initial = $state({ requireApproval: false, docsExternalUrl: '' }) let loading = $state(true) let saving = $state(false) - const dirty = $derived(requireApproval !== initial) + const dirty = $derived( + requireApproval !== initial.requireApproval || docsExternalUrl !== initial.docsExternalUrl, + ) function extractError(error: unknown): string { const e = error as { value?: unknown; status?: number } @@ -29,9 +33,10 @@ try { const { data, error } = await eden.api.admin.instance.get() if (error) throw new Error(extractError(error)) - const res = data as { requireApproval: boolean } + const res = data as { requireApproval: boolean; docsExternalUrl: string | null } requireApproval = res.requireApproval - initial = res.requireApproval + docsExternalUrl = res.docsExternalUrl ?? '' + initial = { requireApproval, docsExternalUrl } } catch (e) { toast.error(e instanceof Error ? e.message : m.admin_instance_load_failed()) } finally { @@ -42,10 +47,14 @@ async function save() { saving = true try { - const { error } = await eden.api.admin.instance.put({ requireApproval }) + const { error } = await eden.api.admin.instance.put({ + requireApproval, + docsExternalUrl: docsExternalUrl.trim() === '' ? null : docsExternalUrl.trim(), + }) if (error) throw new Error(extractError(error)) toast.success(m.admin_instance_saved()) - initial = requireApproval + docsExternalUrl = docsExternalUrl.trim() + initial = { requireApproval, docsExternalUrl } } catch (e) { toast.error(e instanceof Error ? e.message : m.admin_instance_save_failed()) } finally { @@ -54,7 +63,8 @@ } function discard() { - requireApproval = initial + requireApproval = initial.requireApproval + docsExternalUrl = initial.docsExternalUrl } onMount(load) @@ -88,6 +98,25 @@ {/if} + + + + {m.admin_instance_docs_url_title()} + {m.admin_instance_docs_url_subtitle()} + + + {#if loading} +

{m.common_loading()}

+ {:else} + + {/if} +
+
From f4d14bad12b30fc945f3d3c9f7bd08d07c76726c Mon Sep 17 00:00:00 2001 From: NewtTheWolf Date: Mon, 3 Aug 2026 20:05:54 +0200 Subject: [PATCH 2/2] style: prettier formatting for General.svelte --- apps/frontend/src/routes/admin/instance/tabs/General.svelte | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/frontend/src/routes/admin/instance/tabs/General.svelte b/apps/frontend/src/routes/admin/instance/tabs/General.svelte index 51a0a85..949b305 100644 --- a/apps/frontend/src/routes/admin/instance/tabs/General.svelte +++ b/apps/frontend/src/routes/admin/instance/tabs/General.svelte @@ -18,9 +18,7 @@ let loading = $state(true) let saving = $state(false) - const dirty = $derived( - requireApproval !== initial.requireApproval || docsExternalUrl !== initial.docsExternalUrl, - ) + const dirty = $derived(requireApproval !== initial.requireApproval || docsExternalUrl !== initial.docsExternalUrl) function extractError(error: unknown): string { const e = error as { value?: unknown; status?: number }