Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/configurable-attachment-folder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@inkeep/open-knowledge': patch
---

Allow `content.attachmentFolderPath` to choose where pasted or dropped assets are stored.
1 change: 1 addition & 0 deletions packages/core/src/config/field-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/config/schema-jsonschema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/config/schema.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand Down Expand Up @@ -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({
Expand Down
16 changes: 16 additions & 0 deletions packages/server/src/api-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -176,6 +178,7 @@ describe('handleUploadAsset', () => {
contentDir,
getFileIndex: () => new Map(),
serverInstanceId: 'test-instance',
getAttachmentFolderPath: () => attachmentFolderPath,
});

const { createServer } = await import('node:http');
Expand Down Expand Up @@ -238,6 +241,19 @@ describe('handleUploadAsset', () => {
expect((body as Record<string, unknown>).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');
Expand Down
14 changes: 11 additions & 3 deletions packages/server/src/api-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1791,6 +1791,7 @@ export interface ApiExtensionOptions {
semanticSearch?: SemanticSearchService;
getSemanticSimilarityFloor?: () => number | undefined;
embeddingsSecretsFile?: string;
getAttachmentFolderPath?: () => string;
}

interface WorkspaceSearchCacheEntry {
Expand Down Expand Up @@ -1886,6 +1887,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
semanticSearch,
getSemanticSimilarityFloor,
embeddingsSecretsFile,
getAttachmentFolderPath = () => DEFAULT_ATTACHMENT_FOLDER_PATH,
ephemeral = false,
} = options;

Expand Down Expand Up @@ -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.', {
Expand Down Expand Up @@ -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',
Expand All @@ -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;
Expand All @@ -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',
Expand All @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions packages/server/src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)', () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/server/src/server-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
CONFIG_DOC_NAME_USER,
CONFIG_DOC_NAMES,
createBasenameIndex,
DEFAULT_ATTACHMENT_FOLDER_PATH,
humanFormat,
type MarkdownManager,
type Principal,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -731,6 +747,7 @@ export function createServer(options: ServerOptions): ServerInstance {
getSyncEngine: () => syncEngine,
localOpCliArgs,
projectDir,
getAttachmentFolderPath: readProjectAttachmentFolderPath,
resolveEmbed,
getPrincipal: () => loadedPrincipal,
homeDirOverride: configHomedirOverride,
Expand Down
Loading