Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/api/src/routes/api/admin/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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<string> = new Set(BUCKETS.map((b) => b.id))
for (const r of body.rateLimits) {
Expand Down Expand Up @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/routes/api/instance/info.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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: {
Expand All @@ -23,6 +27,7 @@ export default new Elysia().get(
response: {
200: t.Object({
appUrlSchemes: t.Array(appUrlSchemeSchema),
docsExternalUrl: t.Nullable(t.String()),
}),
},
},
Expand Down
52 changes: 52 additions & 0 deletions apps/api/tests/routes/admin-instance-docs-url.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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()
})
})
2 changes: 2 additions & 0 deletions apps/frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "保存失败",
Expand Down
6 changes: 5 additions & 1 deletion apps/frontend/src/lib/components/Footer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -105,7 +106,10 @@
<div class="lg:col-span-3 space-y-4">
<div class="font-medium text-foreground text-xs uppercase tracking-wider">{m.footer_developers()}</div>
<div class="flex flex-col gap-2.5 text-muted-foreground">
<a href="/docs/plugin-development" class="hover:text-foreground transition-colors w-fit">
<a
href={instanceInfo.docsExternalUrl ?? '/docs/plugin-development'}
class="hover:text-foreground transition-colors w-fit"
>
{m.docs_plugin_dev_title()}
</a>
<a
Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/src/lib/components/Header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
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 { m } from '$lib/paraglide/messages'

const navLinks = $derived(
[
{ href: '/plugins', label: m.nav_plugins(), show: true },
{ href: '/docs/plugin-development', label: m.nav_docs(), show: true },
{ href: instanceInfo.docsExternalUrl ?? '/docs/plugin-development', label: m.nav_docs(), show: true },
{ href: '/requests', label: m.nav_requests(), show: features.requestsEnabled },
{ href: '/submit', label: m.nav_submit(), show: features.submissionsEnabled },
].filter((l) => l.show),
Expand Down
7 changes: 7 additions & 0 deletions apps/frontend/src/lib/stores/instance-info.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ export type AppUrlScheme = {

export type InstanceInfo = {
appUrlSchemes: AppUrlScheme[]
docsExternalUrl: string | null
}

const DEFAULTS: InstanceInfo = {
appUrlSchemes: [],
docsExternalUrl: null,
}

function createInstanceInfoStore() {
Expand All @@ -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
}
Expand All @@ -48,6 +52,9 @@ function createInstanceInfoStore() {
get appUrlSchemes() {
return state.appUrlSchemes
},
get docsExternalUrl() {
return state.docsExternalUrl
},
get loaded() {
return loaded
},
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -19,6 +20,7 @@
branding.refresh()
features.refresh()
homeCopy.refresh()
instanceInfo.refresh()
})
</script>

Expand Down
41 changes: 34 additions & 7 deletions apps/frontend/src/routes/admin/instance/tabs/General.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@
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 }
Expand All @@ -29,9 +31,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 {
Expand All @@ -42,10 +45,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 {
Expand All @@ -54,7 +61,8 @@
}

function discard() {
requireApproval = initial
requireApproval = initial.requireApproval
docsExternalUrl = initial.docsExternalUrl
}

onMount(load)
Expand Down Expand Up @@ -88,6 +96,25 @@
{/if}
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle class="text-base">{m.admin_instance_docs_url_title()}</CardTitle>
<CardDescription>{m.admin_instance_docs_url_subtitle()}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if loading}
<p class="text-sm text-muted-foreground">{m.common_loading()}</p>
{:else}
<Input
type="url"
bind:value={docsExternalUrl}
placeholder="https://tabularis.dev/wiki/plugin-development"
class="max-w-xl"
/>
{/if}
</CardContent>
</Card>
</div>

<StickySaveBar {dirty} {saving} onSave={save} onDiscard={discard} />
Loading