From 0d00cf0a29add4fe46206b5480c9ef8ab2ab7840 Mon Sep 17 00:00:00 2001 From: Roz Date: Mon, 17 Aug 2026 13:59:01 +0200 Subject: [PATCH 1/5] feat: add secure peer quick links --- .../src/__tests__/config-management.test.ts | 167 +++++++++++++++++- apps/backend/src/__tests__/config.test.ts | 91 +++++++++- apps/backend/src/lib/config.ts | 69 ++++++++ apps/backend/src/management-app.ts | 5 + .../src/modules/config/config.service.ts | 10 +- .../quick-links/quick-links.controller.ts | 73 ++++++++ .../modules/quick-links/quick-links.router.ts | 22 +++ .../modules/quick-links/quick-links.schema.ts | 9 + apps/ui/app/components/JackConfigForm.vue | 43 ++++- apps/ui/app/components/PeerForm.vue | 4 +- apps/ui/app/components/PeersSection.vue | 39 +++- .../app/components/QuickLinkGenerateModal.vue | 140 +++++++++++++++ .../app/components/QuickLinkImportModal.vue | 61 +++++++ apps/ui/app/pages/settings.vue | 25 ++- apps/ui/app/types/management.ts | 20 ++- apps/ui/app/utils/quick-link.test.ts | 65 +++++++ apps/ui/app/utils/quick-link.ts | 110 ++++++++++++ 17 files changed, 935 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/modules/quick-links/quick-links.controller.ts create mode 100644 apps/backend/src/modules/quick-links/quick-links.router.ts create mode 100644 apps/backend/src/modules/quick-links/quick-links.schema.ts create mode 100644 apps/ui/app/components/QuickLinkGenerateModal.vue create mode 100644 apps/ui/app/components/QuickLinkImportModal.vue create mode 100644 apps/ui/app/utils/quick-link.test.ts create mode 100644 apps/ui/app/utils/quick-link.ts diff --git a/apps/backend/src/__tests__/config-management.test.ts b/apps/backend/src/__tests__/config-management.test.ts index 7fd1d4a..2a783bd 100644 --- a/apps/backend/src/__tests__/config-management.test.ts +++ b/apps/backend/src/__tests__/config-management.test.ts @@ -1,4 +1,5 @@ import type { Envs } from '../lib/envs' +import { Buffer } from 'node:buffer' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -138,11 +139,12 @@ async function makeMutableApp(managementKey = 'mgmt-secret') { database.exec('pragma foreign_keys = ON') const db = drizzle({ client: database, schema }) runMigrations(db) + const authRepos = makeAuthRepos(db) const downloadsRepository = new DownloadsRepository(db) const configService = await ConfigService.fromFile({ path, connectorManager, downloadsRepository }) - const app = getManagementApp({ environment: 'test', managementKey, connectors: connectorManager, configService }) - const mainApp = getApp(makeEnvs(managementKey), config, connectorManager, { downloadsRepository, ...makeAuthRepos(db) }) - return { app, mainApp, path, connectorManager, downloadsRepository, database } + const app = getManagementApp({ environment: 'test', managementKey, connectors: connectorManager, configService, apiKeysRepository: authRepos.apiKeysRepository }) + const mainApp = getApp(makeEnvs(managementKey), config, connectorManager, { downloadsRepository, ...authRepos }) + return { app, mainApp, path, connectorManager, downloadsRepository, database, configService, apiKeysRepository: authRepos.apiKeysRepository } } const KEY = { 'X-Management-Key': 'mgmt-secret' } as const @@ -548,6 +550,47 @@ describe('Management API jack config', () => { expect(await get.json()).toEqual({ internalUrl: 'http://jack.test:5225', apiKey: { env: 'JACK_X' } }) }) + test('PATCH preserves external header refs while the service resolves them on demand', async () => { + process.env.CF_ACCESS_ID = 'resolved-client-id' + const { app, path, configService } = await makeMutableApp() + + const patch = await app.request('/config/jack', { + method: 'PATCH', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + internalUrl: 'http://jack.test:5225', + external: { + url: 'https://jack.example.com', + headers: { + 'CF-Access-Client-Id': { env: 'CF_ACCESS_ID' }, + 'CF-Access-Client-Secret': 'literal-secret', + }, + ignored: true, + }, + }), + }) + expect(patch.status).toBe(200) + + const onDisk = jsonc.parse(await Bun.file(path).text()) as { jack: { external: unknown } } + expect(onDisk.jack.external).toEqual({ + url: 'https://jack.example.com', + headers: { + 'CF-Access-Client-Id': { env: 'CF_ACCESS_ID' }, + 'CF-Access-Client-Secret': 'literal-secret', + }, + }) + + const get = await app.request('/config/jack', { headers: KEY }) + expect((await get.json() as any).external.headers['CF-Access-Client-Id']).toEqual({ env: 'CF_ACCESS_ID' }) + expect(configService.getResolvedExternalJack()).toEqual({ + url: 'https://jack.example.com', + headers: { + 'CF-Access-Client-Id': 'resolved-client-id', + 'CF-Access-Client-Secret': 'literal-secret', + }, + }) + }) + test('PATCH with no apiKey persists a jack block without one (optional)', async () => { const { app, path } = await makeMutableApp() @@ -587,6 +630,124 @@ describe('Management API jack config', () => { }) }) +describe('Management API quick links', () => { + test('POST /quick-links returns a ready-to-share link with resolved headers and a fresh key', async () => { + process.env.CF_ACCESS_ID = 'resolved-client-id' + const { app, apiKeysRepository } = await makeMutableApp() + + await app.request('/config/jack', { + method: 'PATCH', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + internalUrl: 'http://jack.test:5225', + external: { + url: 'https://jack.example.com', + headers: { 'CF-Access-Client-Id': { env: 'CF_ACCESS_ID' } }, + }, + }), + }) + + const res = await app.request('/quick-links', { + method: 'POST', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Roz’s Jack', description: 'Shared with a friend' }), + }) + + expect(res.status).toBe(201) + expect(res.headers.get('Cache-Control')).toBe('no-store') + const body = await res.json() as { link: string, key: Record } + expect(body.link.startsWith('jack-link:v1:')).toBe(true) + expect(body.key).not.toHaveProperty('key') + + const encoded = body.link.slice('jack-link:v1:'.length) + const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as { + v: number + type: string + name: string + url: string + apiKey: string + headers: Record + } + expect(payload).toEqual({ + v: 1, + type: 'peer', + name: 'Roz’s Jack', + url: 'https://jack.example.com', + apiKey: expect.any(String), + headers: { 'CF-Access-Client-Id': 'resolved-client-id' }, + }) + expect(apiKeysRepository.resolve(payload.apiKey).status).toBe('ok') + }) + + test('does not create an API key when the external profile is missing', async () => { + const { app, apiKeysRepository } = await makeMutableApp() + const res = await app.request('/quick-links', { + method: 'POST', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Friend Jack' }), + }) + + expect(res.status).toBe(400) + expect(apiKeysRepository.list()).toHaveLength(0) + }) + + test('does not create an API key when an external secret can no longer resolve', async () => { + process.env.TEMP_EXTERNAL_HEADER = 'available-while-saving' + const { app, apiKeysRepository } = await makeMutableApp() + await app.request('/config/jack', { + method: 'PATCH', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + internalUrl: 'http://jack.test:5225', + external: { + url: 'https://jack.example.com', + headers: { Authorization: { env: 'TEMP_EXTERNAL_HEADER' } }, + }, + }), + }) + delete process.env.TEMP_EXTERNAL_HEADER + + const res = await app.request('/quick-links', { + method: 'POST', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Friend Jack' }), + }) + + expect(res.status).toBe(400) + expect(apiKeysRepository.list()).toHaveLength(0) + }) + + test('revokes the fresh API key when the generated link exceeds the import limit', async () => { + const { app, apiKeysRepository } = await makeMutableApp() + await app.request('/config/jack', { + method: 'PATCH', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + internalUrl: 'http://jack.test:5225', + external: { + url: 'https://jack.example.com', + headers: { Authorization: 'x'.repeat(30_000) }, + }, + }), + }) + + const res = await app.request('/quick-links', { + method: 'POST', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Friend Jack' }), + }) + + expect(res.status).toBe(400) + expect(apiKeysRepository.list()).toHaveLength(0) + }) + + test('the public app never exposes /quick-links', async () => { + const { mainApp } = await makeMutableApp() + const res = await mainApp.request('/quick-links', { method: 'POST', headers: { 'x-api-key': 'test-api-key' } }) + expect(res.status).toBe(404) + }) +}) + describe('Management API downloads config', () => { // Seed a config file that already carries a downloads block, so the partial-patch // behaviour (merge onto what's stored) has something to merge onto. diff --git a/apps/backend/src/__tests__/config.test.ts b/apps/backend/src/__tests__/config.test.ts index f8fd43b..27aec33 100644 --- a/apps/backend/src/__tests__/config.test.ts +++ b/apps/backend/src/__tests__/config.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import z from 'zod' -import { ConfigSecret, migrateConfig, MIGRATIONS } from '../lib/config' +import { ConfigSecret, ExternalJackConfig, migrateConfig, MIGRATIONS, RawExternalJackConfig } from '../lib/config' const HEX_KEY = '0123456789abcdef0123456789abcdef' @@ -94,6 +94,95 @@ describe('configSecret', () => { }) }) +describe('external Jack config', () => { + const savedEnv = { ...process.env } + + beforeEach(() => { + process.env.CF_ACCESS_ID = 'resolved-client-id' + }) + + afterEach(() => { + process.env = { ...savedEnv } + }) + + test('resolves ConfigSecret headers for quick-link generation', () => { + expect(ExternalJackConfig.parse({ + url: 'https://jack.example.com', + headers: { + 'CF-Access-Client-Id': { env: 'CF_ACCESS_ID' }, + 'CF-Access-Client-Secret': 'literal-secret', + }, + })).toEqual({ + url: 'https://jack.example.com', + headers: { + 'CF-Access-Client-Id': 'resolved-client-id', + 'CF-Access-Client-Secret': 'literal-secret', + }, + }) + }) + + test('preserves ConfigSecret refs in the raw schema', () => { + expect(RawExternalJackConfig.parse({ + url: 'https://jack.example.com', + headers: { 'CF-Access-Client-Id': { env: 'CF_ACCESS_ID' } }, + ignored: true, + })).toEqual({ + url: 'https://jack.example.com', + headers: { 'CF-Access-Client-Id': { env: 'CF_ACCESS_ID' } }, + }) + }) + + test.each(['X-Api-Key', 'Host', 'Content-Length', 'Connection', 'Transfer-Encoding'])( + 'rejects the reserved header %s', + (header) => { + expect(RawExternalJackConfig.safeParse({ + url: 'https://jack.example.com', + headers: { [header]: 'unsafe' }, + }).success).toBe(false) + }, + ) + + test('rejects non-HTTP external URLs', () => { + expect(RawExternalJackConfig.safeParse({ url: 'file:///etc/passwd' }).success).toBe(false) + }) + + test.each([ + 'https://user@jack.example.com', + 'https://user:password@jack.example.com', + ])('rejects external URLs containing userinfo credentials: %s', (url) => { + expect(ExternalJackConfig.safeParse({ url }).success).toBe(false) + expect(RawExternalJackConfig.safeParse({ url }).success).toBe(false) + }) + + test('rejects external header values containing line breaks', () => { + expect(ExternalJackConfig.safeParse({ + url: 'https://jack.example.com', + headers: { Authorization: 'safe\r\nInjected: value' }, + }).success).toBe(false) + }) + + test.each(['constructor', 'prototype', '__proto__'])( + 'rejects the dangerous external header name %s', + (header) => { + const headers = Object.create(null) as Record + headers[header] = 'value' + expect(ExternalJackConfig.safeParse({ url: 'https://jack.example.com', headers }).success).toBe(false) + }, + ) + + test('rejects more headers than the quick-link decoder accepts', () => { + const headers = Object.fromEntries(Array.from({ length: 101 }, (_, index) => [`X-Test-${index}`, 'value'])) + expect(ExternalJackConfig.safeParse({ url: 'https://jack.example.com', headers }).success).toBe(false) + expect(RawExternalJackConfig.safeParse({ url: 'https://jack.example.com', headers }).success).toBe(false) + }) + + test('rejects case-insensitive duplicate external header names', () => { + const headers = { Authorization: 'first', authorization: 'second' } + expect(ExternalJackConfig.safeParse({ url: 'https://jack.example.com', headers }).success).toBe(false) + expect(RawExternalJackConfig.safeParse({ url: 'https://jack.example.com', headers }).success).toBe(false) + }) +}) + describe('migrateConfig', () => { test('migrates a versionless config up to the latest version', () => { const result = migrateConfig({ servers: [], peers: [] }) diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 2235bfc..b47a72c 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -149,6 +149,71 @@ export const RawPeerConfig = z.object({ export type RawPeerConfig = z.infer +const RESERVED_EXTERNAL_HEADERS = new Set([ + 'x-api-key', + 'host', + 'content-length', + 'connection', + 'transfer-encoding', +]) +const DANGEROUS_EXTERNAL_HEADER_NAMES = new Set(['__proto__', 'prototype', 'constructor']) +const HTTP_HEADER_LINE_BREAK = /[\r\n]/ + +const ExternalHeaderName = z.string() + .trim() + .min(1) + .regex(/^[!#$%&'*+\-.^`|~\w]+$/, 'Invalid HTTP header name') + .refine(name => !DANGEROUS_EXTERNAL_HEADER_NAMES.has(name.toLowerCase()), 'Dangerous HTTP header name') + .refine(name => !RESERVED_EXTERNAL_HEADERS.has(name.toLowerCase()), 'Reserved HTTP header') + +const ExternalHeaderValue = z.string() + .min(1) + .refine(value => !HTTP_HEADER_LINE_BREAK.test(value), 'HTTP header values cannot contain line breaks') + +const ExternalJackUrl = z.url().refine((value) => { + const url = new URL(value) + return (url.protocol === 'http:' || url.protocol === 'https:') + && !url.username + && !url.password +}, 'External URL must use HTTP or HTTPS and cannot contain userinfo credentials') + +// Inspect raw keys before z.record builds its output object. In particular, +// assigning `__proto__` onto a normal object can otherwise mutate its prototype +// instead of surviving as an own key for the key schema to validate. +const ExternalHeadersObject = z.unknown().superRefine((headers, ctx) => { + if (typeof headers !== 'object' || headers === null || Array.isArray(headers)) + return + const normalizedNames = new Set() + for (const name of Object.keys(headers)) { + const normalizedName = name.toLowerCase() + if (DANGEROUS_EXTERNAL_HEADER_NAMES.has(normalizedName)) + ctx.addIssue({ code: 'custom', message: 'Dangerous HTTP header name' }) + if (normalizedNames.has(normalizedName)) + ctx.addIssue({ code: 'custom', message: 'Duplicate HTTP header name' }) + normalizedNames.add(normalizedName) + } +}) + +const ResolvedExternalHeaders = ExternalHeadersObject.pipe(z.record(ExternalHeaderName, ConfigSecret(ExternalHeaderValue))) + .refine(headers => Object.keys(headers).length <= 100, 'At most 100 external headers are allowed') + +const RawExternalHeaders = ExternalHeadersObject.pipe(z.record(ExternalHeaderName, RawConfigSecret)) + .refine(headers => Object.keys(headers).length <= 100, 'At most 100 external headers are allowed') + +export const ExternalJackConfig = z.object({ + url: ExternalJackUrl, + headers: ResolvedExternalHeaders.default({}), +}) + +export type ExternalJackConfig = z.infer + +export const RawExternalJackConfig = z.object({ + url: ExternalJackUrl, + headers: RawExternalHeaders.optional(), +}) + +export type RawExternalJackConfig = z.infer + export const JackConfig = z.object({ internalUrl: z.url(), // The single "Main API key" (deprecated). Optional: a jack block can carry @@ -157,6 +222,9 @@ export const JackConfig = z.object({ apiKey: ConfigSecret().optional(), // Optional TMDB v3 API key for enriching peer catalogs with artwork/metadata. tmdbApiKey: ConfigSecret().optional(), + // How another Jack reaches this instance. Header secrets resolve only when a + // quick link is generated; raw refs remain intact in the persisted config. + external: ExternalJackConfig.optional(), }) export type JackConfig = z.infer @@ -167,6 +235,7 @@ export const RawJackConfig = z.object({ internalUrl: z.url(), apiKey: RawConfigSecret.optional(), tmdbApiKey: RawConfigSecret.optional(), + external: RawExternalJackConfig.optional(), }) export type RawJackConfig = z.infer diff --git a/apps/backend/src/management-app.ts b/apps/backend/src/management-app.ts index 26258b4..5bde92e 100644 --- a/apps/backend/src/management-app.ts +++ b/apps/backend/src/management-app.ts @@ -23,6 +23,8 @@ import { getDownloadsManagementRouter } from './modules/downloads/downloads.rout import { logHub } from './modules/logging/log-store' import { LogsController } from './modules/logging/logs.controller' import { getLogsRouter } from './modules/logging/logs.router' +import { QuickLinksController } from './modules/quick-links/quick-links.controller' +import { getQuickLinksRouter } from './modules/quick-links/quick-links.router' import { StatusController } from './modules/status/status.controller' import { getStatusRouter } from './modules/status/status.router' @@ -85,6 +87,9 @@ export function getManagementApp(params: { if (params.apiKeysRepository) { const apiKeysController = new ApiKeysController(params.apiKeysRepository) app.route('/api-keys', getApiKeysRouter(apiKeysController)) + + if (params.configService) + app.route('/quick-links', getQuickLinksRouter(new QuickLinksController(params.configService, apiKeysController))) } // The management API is key-guarded and serves the admin UI, so it exposes diff --git a/apps/backend/src/modules/config/config.service.ts b/apps/backend/src/modules/config/config.service.ts index d7772b5..e07330d 100644 --- a/apps/backend/src/modules/config/config.service.ts +++ b/apps/backend/src/modules/config/config.service.ts @@ -4,7 +4,7 @@ import type { ConnectorManager } from '../../lib/servers' import type { DownloadsRepository } from '../downloads/downloads.repository' import { jsonc } from 'jsonc' import { atomicWriteFile } from '../../lib/atomic-write' -import { DownloadsConfig, JackConfig, PeerConfig, RawDownloadsConfig, RawJackConfig, RawPeerConfig, RawServerConfig, ServerConfig } from '../../lib/config' +import { DownloadsConfig, ExternalJackConfig, JackConfig, PeerConfig, RawDownloadsConfig, RawJackConfig, RawPeerConfig, RawServerConfig, ServerConfig } from '../../lib/config' import { ConflictError } from '../../lib/errors/ConflictError' import { NotFoundError } from '../../lib/errors/NotFoundError' import { generateId } from '../../lib/servers/base' @@ -108,6 +108,14 @@ export class ConfigService { return RawJackConfig.parse(jack) } + /** Resolve the saved external access profile only when a quick link needs it. */ + getResolvedExternalJack(): ExternalJackConfig | null { + const external = this.raw.jack?.external + if (!external) + return null + return ExternalJackConfig.parse(external) + } + /** * The persisted `downloads` block, or null when the file has none (downloads are * disabled). Read straight from the file — no secrets live in this block — so the diff --git a/apps/backend/src/modules/quick-links/quick-links.controller.ts b/apps/backend/src/modules/quick-links/quick-links.controller.ts new file mode 100644 index 0000000..0e5bc10 --- /dev/null +++ b/apps/backend/src/modules/quick-links/quick-links.controller.ts @@ -0,0 +1,73 @@ +import type { ApiKeyResponse, ApiKeysController } from '../api-keys/api-keys.controller' +import type { ConfigService } from '../config/config.service' +import type { CreateQuickLinkBody } from './quick-links.schema' +import { Buffer } from 'node:buffer' +import { z } from 'zod' +import { BadRequestError } from '../../lib/errors/BadRequestError' + +export interface QuickLinkPayload { + v: 1 + type: 'peer' + name: string + url: string + apiKey: string + headers: Record +} + +export interface CreateQuickLinkResponse { + link: string + key: ApiKeyResponse +} + +export function encodeQuickLink(payload: QuickLinkPayload): string { + const link = `jack-link:v1:${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')}` + if (link.length > 32_768) + throw new BadRequestError('The configured external access profile is too large for a quick link') + return link +} + +export class QuickLinksController { + constructor( + private readonly configService: ConfigService, + private readonly apiKeysController: ApiKeysController, + ) {} + + create(input: CreateQuickLinkBody): CreateQuickLinkResponse { + // Resolve every ConfigSecret before issuing a credential. A broken or missing + // external profile therefore cannot leave an orphan API key behind. + let external + try { + external = this.configService.getResolvedExternalJack() + } + catch (error) { + if (error instanceof z.ZodError) + throw new BadRequestError('The external access profile contains a secret that could not be resolved') + throw error + } + if (!external) + throw new BadRequestError('Configure Jack external access before generating a quick link') + + const created = this.apiKeysController.create(input) + const { key: rawKey, ...key } = created + + try { + return { + link: encodeQuickLink({ + v: 1, + type: 'peer', + name: input.name, + url: external.url, + apiKey: rawKey, + headers: external.headers, + }), + key, + } + } + catch (error) { + // Encoding should be infallible for the validated string-only payload, but + // compensate if that contract changes so a credential is never orphaned. + this.apiKeysController.delete(created.id) + throw error + } + } +} diff --git a/apps/backend/src/modules/quick-links/quick-links.router.ts b/apps/backend/src/modules/quick-links/quick-links.router.ts new file mode 100644 index 0000000..d067fb5 --- /dev/null +++ b/apps/backend/src/modules/quick-links/quick-links.router.ts @@ -0,0 +1,22 @@ +import type { QuickLinksController } from './quick-links.controller' +import { Hono } from 'hono' +import { describeRoute, validator as zValidator } from 'hono-openapi' +import { CreateQuickLinkBody } from './quick-links.schema' + +export function getQuickLinksRouter(controller: QuickLinksController) { + const app = new Hono() + + app.post('/', describeRoute({ + tags: ['Quick links'], + summary: 'Generate a peer quick link', + description: 'Resolves the configured external access profile, issues a fresh revocable API key, and returns a ready-to-share quick link. The link contains credentials and is returned only once.', + security: [{ 'X-Management-Key': [] }], + responses: { 201: { description: 'Quick link generated', content: { 'application/json': {} } } }, + }), zValidator('json', CreateQuickLinkBody), (c) => { + const result = controller.create(c.req.valid('json')) + c.header('Cache-Control', 'no-store') + return c.json(result, 201) + }) + + return app +} diff --git a/apps/backend/src/modules/quick-links/quick-links.schema.ts b/apps/backend/src/modules/quick-links/quick-links.schema.ts new file mode 100644 index 0000000..86e2941 --- /dev/null +++ b/apps/backend/src/modules/quick-links/quick-links.schema.ts @@ -0,0 +1,9 @@ +import z from 'zod' + +export const CreateQuickLinkBody = z.object({ + name: z.string().trim().min(1).max(100), + description: z.string().max(500).nullish(), + expiresAt: z.string().datetime().nullish(), +}) + +export type CreateQuickLinkBody = z.infer diff --git a/apps/ui/app/components/JackConfigForm.vue b/apps/ui/app/components/JackConfigForm.vue index 65b978f..f962558 100644 --- a/apps/ui/app/components/JackConfigForm.vue +++ b/apps/ui/app/components/JackConfigForm.vue @@ -15,13 +15,28 @@ const state = reactive({ internalUrl: props.initial?.internalUrl ?? '', apiKey: (props.initial?.apiKey ?? null) as SecretRef | null, tmdbApiKey: (props.initial?.tmdbApiKey ?? null) as SecretRef | null, + externalUrl: props.initial?.external?.url ?? '', + externalHeaders: { ...(props.initial?.external?.headers ?? {}) } as Record, }) // internalUrl is the only required field; the Main API key is optional/clearable. function validate(s: typeof state): FormError[] { + const errors: FormError[] = [] if (!s.internalUrl.trim()) - return [{ name: 'internalUrl', message: 'Enter the internal URL.' }] - return [] + errors.push({ name: 'internalUrl', message: 'Enter the internal URL.' }) + if (!s.externalUrl.trim() && Object.keys(s.externalHeaders).length) + errors.push({ name: 'externalUrl', message: 'Enter the external URL for these headers.' }) + if (s.externalUrl.trim()) { + try { + const url = new URL(s.externalUrl) + if (!['http:', 'https:'].includes(url.protocol)) + errors.push({ name: 'externalUrl', message: 'Use an HTTP or HTTPS URL.' }) + } + catch { + errors.push({ name: 'externalUrl', message: 'Enter a valid external URL.' }) + } + } + return errors } function onSubmit() { @@ -31,6 +46,12 @@ function onSubmit() { input.apiKey = state.apiKey if (state.tmdbApiKey) input.tmdbApiKey = state.tmdbApiKey + if (state.externalUrl.trim()) { + input.external = { + url: state.externalUrl.trim(), + ...(Object.keys(state.externalHeaders).length ? { headers: state.externalHeaders } : {}), + } + } emit('submit', input) } @@ -60,6 +81,24 @@ function onSubmit() { + + + + + + + + + + +
diff --git a/apps/ui/app/components/PeerForm.vue b/apps/ui/app/components/PeerForm.vue index a2a2900..a9e2745 100644 --- a/apps/ui/app/components/PeerForm.vue +++ b/apps/ui/app/components/PeerForm.vue @@ -3,13 +3,13 @@ import type { FormError } from '@nuxt/ui' import type { PeerInput, PeerItem, SecretRef } from '~/types/management' const props = defineProps<{ - initial?: PeerItem | null + initial?: PeerItem | PeerInput | null submitting?: boolean error?: string | null }>() const emit = defineEmits<{ submit: [PeerInput, boolean], cancel: [] }>() -const editing = computed(() => Boolean(props.initial)) +const editing = computed(() => Boolean(props.initial && 'id' in props.initial)) const state = reactive({ name: props.initial?.name ?? '', diff --git a/apps/ui/app/components/PeersSection.vue b/apps/ui/app/components/PeersSection.vue index d49beef..7dea8ae 100644 --- a/apps/ui/app/components/PeersSection.vue +++ b/apps/ui/app/components/PeersSection.vue @@ -6,9 +6,19 @@ const { request, extractError } = useManagement() const { settings, pending, error, reload } = useSettings() const showForm = ref(false) +const showImport = ref(false) const editTarget = ref(null) +const importedInput = ref(null) +const formRevision = ref(0) +const formInitial = computed(() => editTarget.value ?? importedInput.value) const submitting = ref(false) const formError = ref(null) +watch(showForm, (isOpen) => { + if (!isOpen) { + importedInput.value = null + formError.value = null + } +}) const confirmTarget = ref(null) const deleting = ref(false) @@ -50,14 +60,29 @@ function closeConfirm() { function openAdd() { editTarget.value = null + importedInput.value = null formError.value = null + formRevision.value++ showForm.value = true } function openEdit(peer: PeerItem) { editTarget.value = peer + importedInput.value = null + formError.value = null + formRevision.value++ + showForm.value = true +} +function reviewImported(peer: PeerInput) { + editTarget.value = null + importedInput.value = peer formError.value = null + formRevision.value++ showForm.value = true } +function closeForm() { + showForm.value = false + importedInput.value = null +} async function submit(input: PeerInput, force = false) { submitting.value = true @@ -70,7 +95,7 @@ async function submit(input: PeerInput, force = false) { await request(`config/peers/${editTarget.value.id}`, { method: 'PATCH', body: input, query }) else await request('config/peers', { method: 'POST', body: input, query }) - showForm.value = false + closeForm() await reload() } catch (err) { @@ -103,7 +128,10 @@ async function confirmDelete() {