From 5192b587c272116f7c08275b2d16b871d6eaa97d Mon Sep 17 00:00:00 2001 From: icatw <99238504+icatw@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:37:09 +0800 Subject: [PATCH] feat: make upload attachment folder configurable --- .changeset/configurable-attachment-folder.md | 5 +++++ .../core/src/config/field-registry.test.ts | 1 + .../core/src/config/schema-jsonschema.test.ts | 10 ++++++++++ packages/core/src/config/schema.ts | 12 ++++++++++++ packages/server/src/api-extension.test.ts | 16 ++++++++++++++++ packages/server/src/api-extension.ts | 14 +++++++++++--- packages/server/src/config/schema.test.ts | 18 ++++++++++++++++++ packages/server/src/server-factory.ts | 17 +++++++++++++++++ 8 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 .changeset/configurable-attachment-folder.md diff --git a/.changeset/configurable-attachment-folder.md b/.changeset/configurable-attachment-folder.md new file mode 100644 index 00000000..eff662e9 --- /dev/null +++ b/.changeset/configurable-attachment-folder.md @@ -0,0 +1,5 @@ +--- +'@inkeep/open-knowledge': patch +--- + +Allow `content.attachmentFolderPath` to choose where pasted or dropped assets are stored. diff --git a/packages/core/src/config/field-registry.test.ts b/packages/core/src/config/field-registry.test.ts index c469bbe9..c6469083 100644 --- a/packages/core/src/config/field-registry.test.ts +++ b/packages/core/src/config/field-registry.test.ts @@ -145,6 +145,7 @@ describe('ConfigSchema coverage (NR3 — every leaf has fieldRegistry metadata)' .sort(); expect(projectStrict).toEqual([ 'autoSync.default', + 'content.attachmentFolderPath', 'content.dir', 'telemetry.localSink.attributeDenylist', 'telemetry.localSink.enabled', diff --git a/packages/core/src/config/schema-jsonschema.test.ts b/packages/core/src/config/schema-jsonschema.test.ts index db466858..e4ad3a24 100644 --- a/packages/core/src/config/schema-jsonschema.test.ts +++ b/packages/core/src/config/schema-jsonschema.test.ts @@ -33,6 +33,16 @@ const FIXTURES: Fixture[] = [ input: { content: { dir: 'docs' } }, shouldAccept: true, }, + { + name: 'content.attachmentFolderPath=attachments accepted', + input: { content: { attachmentFolderPath: 'attachments' } }, + shouldAccept: true, + }, + { + name: 'content.attachmentFolderPath non-string rejected', + input: { content: { attachmentFolderPath: 12345 } }, + shouldAccept: false, + }, { name: 'content with non-string dir rejected', input: { content: { dir: 12345 } }, diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index f8424de7..65e659a8 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { DEFAULT_ATTACHMENT_FOLDER_PATH } from '../constants/upload.ts'; import { fieldRegistry } from './field-registry.ts'; export const DEFAULT_TELEMETRY_ATTRIBUTE_DENYLIST: readonly string[] = Object.freeze([ @@ -31,9 +32,20 @@ export const ConfigSchema = z.looseObject({ 'Folder OpenKnowledge reads and writes documents under, relative to the project root (the folder that contains .ok/). Defaults to the project root. Exclude paths with .okignore.', }) .default('.'), + attachmentFolderPath: z + .string() + .register(fieldRegistry, { + scope: 'project', + agentSettable: false, + defaultScope: 'project', + description: + 'Folder pasted or dropped assets are written to. Use ./ for the current page folder, / for the content root, ./attachments beside each page, or attachments for a fixed content-relative folder.', + }) + .default(DEFAULT_ATTACHMENT_FOLDER_PATH), }) .default({ dir: '.', + attachmentFolderPath: DEFAULT_ATTACHMENT_FOLDER_PATH, }), appearance: z .looseObject({ diff --git a/packages/server/src/api-extension.test.ts b/packages/server/src/api-extension.test.ts index c7432e5c..6f623cfb 100644 --- a/packages/server/src/api-extension.test.ts +++ b/packages/server/src/api-extension.test.ts @@ -156,10 +156,12 @@ describe('handleUploadAsset', () => { let contentDir: string; let server: import('node:http').Server; let port: number; + let attachmentFolderPath: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'upload-test-')); contentDir = join(tmpDir, 'content'); + attachmentFolderPath = './'; mkdirSync(contentDir, { recursive: true }); mkdirSync(join(contentDir, 'docs'), { recursive: true }); writeFileSync(join(contentDir, 'docs', 'guide.md'), '# Guide'); @@ -176,6 +178,7 @@ describe('handleUploadAsset', () => { contentDir, getFileIndex: () => new Map(), serverInstanceId: 'test-instance', + getAttachmentFolderPath: () => attachmentFolderPath, }); const { createServer } = await import('node:http'); @@ -238,6 +241,19 @@ describe('handleUploadAsset', () => { expect((body as Record).ok).toBeUndefined(); }); + test('stores upload in configured content-relative attachments folder', async () => { + attachmentFolderPath = 'attachments'; + + const res = await uploadImage(createPngBuffer(), 'screenshot.png', 'docs/guide.md'); + const body = (await res.json()) as { src: string; path: string; deduped: boolean }; + expect(res.status).toBe(200); + expect(body.src).toBe('../attachments/screenshot.png'); + expect(body.path).toBe('attachments/screenshot.png'); + expect(body.deduped).toBe(false); + expect(existsSync(join(contentDir, 'attachments', 'screenshot.png'))).toBe(true); + expect(existsSync(join(contentDir, 'docs', 'screenshot.png'))).toBe(false); + }); + test('rejects missing parentDocName', async () => { const formData = new FormData(); formData.append('file', new Blob([createPngBuffer()]), 'test.png'); diff --git a/packages/server/src/api-extension.ts b/packages/server/src/api-extension.ts index 6ad6263e..f60004ca 100644 --- a/packages/server/src/api-extension.ts +++ b/packages/server/src/api-extension.ts @@ -1791,6 +1791,7 @@ export interface ApiExtensionOptions { semanticSearch?: SemanticSearchService; getSemanticSimilarityFloor?: () => number | undefined; embeddingsSecretsFile?: string; + getAttachmentFolderPath?: () => string; } interface WorkspaceSearchCacheEntry { @@ -1886,6 +1887,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { semanticSearch, getSemanticSimilarityFloor, embeddingsSecretsFile, + getAttachmentFolderPath = () => DEFAULT_ATTACHMENT_FOLDER_PATH, ephemeral = false, } = options; @@ -8172,9 +8174,13 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { const resolvedContentDir = resolve(contentDir); const destDir = resolveUploadDestDir( parentDocName, - DEFAULT_ATTACHMENT_FOLDER_PATH, + getAttachmentFolderPath(), resolvedContentDir, ); + const uploadSrcFor = (assetRelPath: string) => { + const parentDir = dirname(parentDocName); + return toPosix(relative(parentDir, assetRelPath)) || basename(assetRelPath); + }; if (!isWithinContentDir(destDir, resolvedContentDir)) { cleanupTempfile(); errorResponse(res, 400, 'urn:ok:error:path-escape', 'Path escape detected.', { @@ -8263,6 +8269,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { if (existing) { cleanupTempfile(); const relPath = toPosix(relative(contentDir, resolve(destDir, existing))); + const src = uploadSrcFor(relPath); log.info( { event: 'upload', @@ -8281,7 +8288,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { res, 200, UploadAssetSuccessSchema, - { src: existing, path: relPath, deduped: true }, + { src, path: relPath, deduped: true }, { handler: 'upload-asset' }, ); return; @@ -8307,6 +8314,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { try { const destFilename = linkTempToFinalWithCollisionRetry(tempPath, destDir, finalFilename); const relPath = toPosix(relative(contentDir, resolve(destDir, destFilename))); + const src = uploadSrcFor(relPath); log.info( { event: 'upload', @@ -8325,7 +8333,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { res, 200, UploadAssetSuccessSchema, - { src: destFilename, path: relPath, deduped: false }, + { src, path: relPath, deduped: false }, { handler: 'upload-asset' }, ); } catch (e) { diff --git a/packages/server/src/config/schema.test.ts b/packages/server/src/config/schema.test.ts index c2c513c7..a86bdfc5 100644 --- a/packages/server/src/config/schema.test.ts +++ b/packages/server/src/config/schema.test.ts @@ -5,6 +5,7 @@ describe('ConfigSchema', () => { test('empty object returns all defaults', () => { const config = ConfigSchema.parse({}); expect(config.content.dir).toBe('.'); + expect(config.content.attachmentFolderPath).toBe('./'); expect(config.appearance.theme).toBeUndefined(); expect(config.editor.wordWrap).toBe(true); expect(config.autoSync.enabled).toBeNull(); @@ -65,6 +66,23 @@ describe('ConfigSchema', () => { }); expect(config.content.dir).toBe('docs'); }); + + test('content.attachmentFolderPath is preserved', () => { + const config = ConfigSchema.parse({ + content: { attachmentFolderPath: 'attachments' }, + }); + expect(config.content.attachmentFolderPath).toBe('attachments'); + }); + + test('content.attachmentFolderPath rejects non-string values', () => { + const result = ConfigSchema.safeParse({ + content: { attachmentFolderPath: 12345 }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain('attachmentFolderPath'); + } + }); }); describe('ConfigSchema (upload surface removed per 2026-04-24 amendment)', () => { diff --git a/packages/server/src/server-factory.ts b/packages/server/src/server-factory.ts index aeb78aa7..4978bb36 100644 --- a/packages/server/src/server-factory.ts +++ b/packages/server/src/server-factory.ts @@ -12,6 +12,7 @@ import { CONFIG_DOC_NAME_USER, CONFIG_DOC_NAMES, createBasenameIndex, + DEFAULT_ATTACHMENT_FOLDER_PATH, humanFormat, type MarkdownManager, type Principal, @@ -272,6 +273,21 @@ export function createServer(options: ServerOptions): ServerInstance { return project.value.autoSync?.default === true; } + function readProjectAttachmentFolderPath(): string { + const project = readConfigSafely({ + absPath: resolveConfigPath('project', projectDir), + sideline: false, + warn: (message) => log.warn({ message }, '[config] could not read project config'), + }); + if (!project.valid) { + log.warn( + {}, + '[config] content.attachmentFolderPath unavailable (project config invalid) — defaulting to current page folder', + ); + } + return project.value.content?.attachmentFolderPath ?? DEFAULT_ATTACHMENT_FOLDER_PATH; + } + function readSemanticSearchConfig(): ResolvedSemanticConfig { return readProjectLocalSemanticConfig(projectDir, { configHomedirOverride, @@ -731,6 +747,7 @@ export function createServer(options: ServerOptions): ServerInstance { getSyncEngine: () => syncEngine, localOpCliArgs, projectDir, + getAttachmentFolderPath: readProjectAttachmentFolderPath, resolveEmbed, getPrincipal: () => loadedPrincipal, homeDirOverride: configHomedirOverride,