diff --git a/apps/backend/src/__tests__/config-management.test.ts b/apps/backend/src/__tests__/config-management.test.ts index 7fd1d4a..a152427 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,50 @@ 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: { + instanceName: 'Roz’s Jack', + 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({ + instanceName: 'Roz’s Jack', + 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({ + instanceName: 'Roz’s Jack', + 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() @@ -585,6 +631,186 @@ describe('Management API jack config', () => { }) expect(res.status).toBe(404) }) + + test('PATCH /config/jack/external updates only external config during a concurrent Jack save', async () => { + const { app } = await makeMutableApp() + await app.request('/config/jack', { + method: 'PATCH', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + internalUrl: 'http://jack.test:5225', + external: { instanceName: 'Old name', url: 'https://old.example.com' }, + }), + }) + + const [jackPatch, externalPatch] = await Promise.all([ + app.request('/config/jack', { + method: 'PATCH', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ internalUrl: 'http://jack-new.test:5225' }), + }), + app.request('/config/jack/external', { + method: 'PATCH', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ instanceName: 'Roz’s Jack', url: 'https://jack.example.com' }), + }), + ]) + + expect(jackPatch.status).toBe(200) + expect(externalPatch.status).toBe(200) + const get = await app.request('/config/jack', { headers: KEY }) + expect(await get.json()).toMatchObject({ + internalUrl: 'http://jack-new.test:5225', + external: { instanceName: 'Roz’s Jack', url: 'https://jack.example.com' }, + }) + }) + + test('DELETE /config/jack/external removes external config only', async () => { + const { app } = await makeMutableApp() + await app.request('/config/jack', { + method: 'PATCH', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + internalUrl: 'http://jack.test:5225', + external: { instanceName: 'Roz’s Jack', url: 'https://jack.example.com' }, + }), + }) + + const remove = await app.request('/config/jack/external', { + method: 'DELETE', + headers: KEY, + }) + expect(remove.status).toBe(200) + + const get = await app.request('/config/jack', { headers: KEY }) + const body = await get.json() as Record + expect(body.internalUrl).toBe('http://jack.test:5225') + expect(body.external).toBeUndefined() + }) +}) + +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: { + instanceName: 'Roz’s Jack', + 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({ + peerName: 'Roz’s Jack', + keyName: 'Friend access', + keyDescription: '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') + expect(body.key).toMatchObject({ name: 'Friend access', description: 'Shared with a friend' }) + + 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({ peerName: 'Friend Jack', keyName: 'Friend access' }), + }) + + 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({ peerName: 'Friend Jack', keyName: 'Friend access' }), + }) + + 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({ peerName: 'Friend Jack', keyName: 'Friend access' }), + }) + + 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', () => { diff --git a/apps/backend/src/__tests__/config.test.ts b/apps/backend/src/__tests__/config.test.ts index f8fd43b..aff265c 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,99 @@ 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({ + instanceName: 'Roz’s Jack', + url: 'https://jack.example.com', + headers: { + 'CF-Access-Client-Id': { env: 'CF_ACCESS_ID' }, + 'CF-Access-Client-Secret': 'literal-secret', + }, + })).toEqual({ + instanceName: 'Roz’s Jack', + 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({ + instanceName: 'Roz’s Jack', + url: 'https://jack.example.com', + headers: { 'CF-Access-Client-Id': { env: 'CF_ACCESS_ID' } }, + ignored: true, + })).toEqual({ + instanceName: 'Roz’s Jack', + 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..1017d58 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -149,6 +149,73 @@ 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({ + instanceName: z.string().trim().min(1).max(100).optional(), + url: ExternalJackUrl, + headers: ResolvedExternalHeaders.default({}), +}) + +export type ExternalJackConfig = z.infer + +export const RawExternalJackConfig = z.object({ + instanceName: z.string().trim().min(1).max(100).optional(), + 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 +224,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 +237,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/lib/servers/arr/arr-add-import.test.ts b/apps/backend/src/lib/servers/arr/arr-add-import.test.ts index 96215fb..424b240 100644 --- a/apps/backend/src/lib/servers/arr/arr-add-import.test.ts +++ b/apps/backend/src/lib/servers/arr/arr-add-import.test.ts @@ -135,13 +135,17 @@ describe('RadarrServerConnector.add', () => { }) describe('RadarrServerConnector.manualImport', () => { - test('imports only the file matching params.paths, carrying movieId/downloadId/quality/languages', async () => { + test('discovers candidates without movieId and imports only the file matching params.paths', async () => { let commandBody: unknown = null + const manualImportUrls: string[] = [] server.use( - http.get(`${RADARR_URL}/api/v3/manualimport`, () => HttpResponse.json([ - { path: '/downloads/Movie.mkv', quality: { quality: { id: 7 } }, languages: [{ id: 1, name: 'English' }], releaseGroup: 'GRP' }, - { path: '/downloads/Other.mkv', quality: { quality: { id: 3 } }, languages: [{ id: 2, name: 'French' }], releaseGroup: 'X' }, - ])), + http.get(`${RADARR_URL}/api/v3/manualimport`, ({ request }) => { + manualImportUrls.push(request.url) + return HttpResponse.json([ + { path: '/downloads/Movie.mkv', quality: { quality: { id: 7 } }, languages: [{ id: 1, name: 'English' }], releaseGroup: 'GRP' }, + { path: '/downloads/Other.mkv', quality: { quality: { id: 3 } }, languages: [{ id: 2, name: 'French' }], releaseGroup: 'X' }, + ]) + }), http.post(`${RADARR_URL}/api/v3/command`, async ({ request }) => { commandBody = await request.json() return HttpResponse.json({ id: 1 }) @@ -157,6 +161,12 @@ describe('RadarrServerConnector.manualImport', () => { }) const body = CommandBody.parse(commandBody) + const manualImportQuery = new URL(manualImportUrls[0]!).searchParams + // Radarr ignores `folder` when movieId is set, scanning the (not yet existing) + // library folder instead of the download folder. + expect(manualImportQuery.get('movieId')).toBeNull() + expect(manualImportQuery.get('folder')).toBe('/downloads') + expect(manualImportQuery.get('filterExistingFiles')).toBe('false') expect(commandId).toBe(1) expect(body.name).toBe('ManualImport') expect(body.importMode).toBe('move') diff --git a/apps/backend/src/lib/servers/arr/radarr.ts b/apps/backend/src/lib/servers/arr/radarr.ts index 63876df..e83c785 100644 --- a/apps/backend/src/lib/servers/arr/radarr.ts +++ b/apps/backend/src/lib/servers/arr/radarr.ts @@ -200,9 +200,13 @@ export class RadarrServerConnector extends ArrServerConnector { throw new BadRequestError(`Radarr cannot import a "${params.target.kind}" target`) const { movieId } = params.target + // No movieId here: Radarr's controller short-circuits on it and lists the files + // already in the movie's library folder, ignoring `folder` entirely (and blowing + // up with DirectoryNotFoundException when that folder doesn't exist yet, which is + // always the case before a first import). The movieId belongs on the command + // payload below, which is what actually maps the files to the movie. const candidates = await this.arrGet('/api/v3/manualimport', { folder: params.folder, - movieId: String(movieId), filterExistingFiles: 'false', }) const wanted = new Set(params.paths) 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.controller.ts b/apps/backend/src/modules/config/config.controller.ts index 39c1e27..0606370 100644 --- a/apps/backend/src/modules/config/config.controller.ts +++ b/apps/backend/src/modules/config/config.controller.ts @@ -134,6 +134,10 @@ export class ConfigController { return this.mutate(s => s.updateJack(input)) } + updateJackExternal(input: unknown) { + return this.mutate(s => s.updateJackExternal(input)) + } + updateDownloads(input: unknown) { return this.mutate(s => s.updateDownloads(input)) } diff --git a/apps/backend/src/modules/config/config.router.ts b/apps/backend/src/modules/config/config.router.ts index 84ad601..a63780d 100644 --- a/apps/backend/src/modules/config/config.router.ts +++ b/apps/backend/src/modules/config/config.router.ts @@ -2,7 +2,7 @@ import type { ConfigController } from './config.controller' import { Hono } from 'hono' import { describeRoute, validator as zValidator } from 'hono-openapi' import { z } from 'zod' -import { RawDownloadsConfig, RawJackConfig, RawPeerConfig, RawServerConfig } from '../../lib/config' +import { RawDownloadsConfig, RawExternalJackConfig, RawJackConfig, RawPeerConfig, RawServerConfig } from '../../lib/config' import { streamSnapshots } from '../../lib/sse' const idParam = z.object({ id: z.string().min(1) }) @@ -70,6 +70,14 @@ export function getConfigRouter(controller: ConfigController) { return c.json(await controller.updateJack(c.req.valid('json'))) }) + app.patch('/jack/external', configDoc('Update external Jack access', 'Atomically replaces only the external access profile.'), zValidator('json', RawExternalJackConfig), async (c) => { + return c.json(await controller.updateJackExternal(c.req.valid('json'))) + }) + + app.delete('/jack/external', configDoc('Remove external Jack access', 'Atomically removes only the external access profile.'), async (c) => { + return c.json(await controller.updateJackExternal(null)) + }) + // Partial patch: the body is merged onto the stored downloads block. Every knob // but `unlinkImportedFiles` is captured at boot, so those land on next restart. app.patch('/downloads', configDoc('Update the downloads block', 'Merges the given fields into the stored downloads config. `unlinkImportedFiles` applies immediately; the other values take effect on next boot.'), zValidator('json', RawDownloadsConfig), async (c) => { diff --git a/apps/backend/src/modules/config/config.service.ts b/apps/backend/src/modules/config/config.service.ts index d7772b5..e8548ea 100644 --- a/apps/backend/src/modules/config/config.service.ts +++ b/apps/backend/src/modules/config/config.service.ts @@ -4,7 +4,8 @@ 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, RawExternalJackConfig, RawJackConfig, RawPeerConfig, RawServerConfig, ServerConfig } from '../../lib/config' +import { BadRequestError } from '../../lib/errors/BadRequestError' import { ConflictError } from '../../lib/errors/ConflictError' import { NotFoundError } from '../../lib/errors/NotFoundError' import { generateId } from '../../lib/servers/base' @@ -108,6 +109,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 @@ -292,7 +301,34 @@ export class ConfigService { JackConfig.parse(input) const rawJack = RawJackConfig.parse(input) return this.enqueue(async () => { - const next = { ...this.raw, jack: rawJack } as RawConfig + const merged = { + ...rawJack, + ...(!Object.hasOwn(rawJack, 'external') && this.raw.jack?.external + ? { external: this.raw.jack.external } + : {}), + } + JackConfig.parse(merged) + const next = { ...this.raw, jack: RawJackConfig.parse(merged) } as RawConfig + await this.persist(next) + this.raw = next + }) + } + + /** Atomically replace or remove only jack.external, preserving every sibling field. */ + async updateJackExternal(input: unknown): Promise { + const rawExternal = input === null ? null : RawExternalJackConfig.parse(input) + if (rawExternal) + ExternalJackConfig.parse(rawExternal) + + return this.enqueue(async () => { + const current = this.raw.jack + if (!current) + throw new BadRequestError('Configure Jack before setting up quick linking') + + const { external: _external, ...jackWithoutExternal } = current + const merged = rawExternal ? { ...jackWithoutExternal, external: rawExternal } : jackWithoutExternal + JackConfig.parse(merged) + const next = { ...this.raw, jack: RawJackConfig.parse(merged) } as RawConfig await this.persist(next) this.raw = next }) 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..e29f2a0 --- /dev/null +++ b/apps/backend/src/modules/quick-links/quick-links.controller.ts @@ -0,0 +1,77 @@ +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({ + name: input.keyName, + description: input.keyDescription, + expiresAt: input.expiresAt, + }) + const { key: rawKey, ...key } = created + + try { + return { + link: encodeQuickLink({ + v: 1, + type: 'peer', + name: input.peerName, + 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..f80d98c --- /dev/null +++ b/apps/backend/src/modules/quick-links/quick-links.schema.ts @@ -0,0 +1,10 @@ +import z from 'zod' + +export const CreateQuickLinkBody = z.object({ + peerName: z.string().trim().min(1).max(100), + keyName: z.string().trim().min(1).max(100), + keyDescription: 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..9ef4ed2 100644 --- a/apps/ui/app/components/JackConfigForm.vue +++ b/apps/ui/app/components/JackConfigForm.vue @@ -19,9 +19,10 @@ const state = reactive({ // 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.' }) + return errors } 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..69b9fca 100644 --- a/apps/ui/app/components/PeersSection.vue +++ b/apps/ui/app/components/PeersSection.vue @@ -7,8 +7,17 @@ const { settings, pending, error, reload } = useSettings() const showForm = 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 +59,30 @@ 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 +} +defineExpose({ reviewImported }) +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) { @@ -154,11 +179,12 @@ async function confirmDelete() { diff --git a/apps/ui/app/components/QuickLinkGenerateModal.vue b/apps/ui/app/components/QuickLinkGenerateModal.vue new file mode 100644 index 0000000..37dda8a --- /dev/null +++ b/apps/ui/app/components/QuickLinkGenerateModal.vue @@ -0,0 +1,142 @@ + + + diff --git a/apps/ui/app/components/QuickLinkImportModal.vue b/apps/ui/app/components/QuickLinkImportModal.vue new file mode 100644 index 0000000..b0023e8 --- /dev/null +++ b/apps/ui/app/components/QuickLinkImportModal.vue @@ -0,0 +1,61 @@ + + + diff --git a/apps/ui/app/components/QuickLinkingSection.vue b/apps/ui/app/components/QuickLinkingSection.vue new file mode 100644 index 0000000..a05483c --- /dev/null +++ b/apps/ui/app/components/QuickLinkingSection.vue @@ -0,0 +1,133 @@ + + + diff --git a/apps/ui/app/pages/settings.vue b/apps/ui/app/pages/settings.vue index 41af592..2c3297b 100644 --- a/apps/ui/app/pages/settings.vue +++ b/apps/ui/app/pages/settings.vue @@ -1,6 +1,6 @@