Merge remote-tracking branch 'gitea/develop' into fix/review-batch-2
# Conflicts: # AGENTS.md # CHANGELOG.md # README.md # apps/server/src/collaboration/collaboration.handler.ts # apps/server/src/common/helpers/prosemirror/html-embed.spec.ts # apps/server/src/common/helpers/prosemirror/html-embed.util.ts # apps/server/src/core/ai-chat/public-share-chat.service.ts # apps/server/src/core/ai-chat/public-share-chat.spec.ts # apps/server/src/core/ai-chat/public-share-workspace-limiter.ts # apps/server/src/core/page/services/page.service.ts # apps/server/src/core/page/transclusion/transclusion.service.ts # apps/server/src/integrations/import/services/file-import-task.service.ts # apps/server/src/integrations/import/services/import.service.ts
This commit is contained in:
@@ -65,21 +65,19 @@ export const MAX_SHARE_MESSAGES = 30;
|
||||
export const MAX_SHARE_MESSAGE_CHARS = 8000;
|
||||
|
||||
/**
|
||||
* Default per-request output cap for the anonymous share assistant. Bounds the
|
||||
* tokens a single anonymous request can generate; worst case = steps x this.
|
||||
*/
|
||||
export const SHARE_AI_MAX_OUTPUT_TOKENS = 512;
|
||||
|
||||
/**
|
||||
* Read the per-request output cap from the environment (overridable seam),
|
||||
* falling back to the sane default. A non-positive / unparseable value uses the
|
||||
* default. Mirrors resolveShareAiWorkspaceMax().
|
||||
* Per-request output-token ceiling for the anonymous assistant. `streamText`
|
||||
* runs up to `stepCountIs(5)` steps, so the worst-case output of one accepted
|
||||
* request is bounded by (steps × this). The per-workspace cap bounds the COUNT
|
||||
* of calls; this bounds the SIZE of each, so a single anonymous call cannot run
|
||||
* up the provider bill even if the per-IP throttle is evaded. Env-overridable
|
||||
* seam; a non-positive or unparseable value falls back to the default.
|
||||
*/
|
||||
export const SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT = 512;
|
||||
export function resolveShareAiMaxOutputTokens(): number {
|
||||
const raw = Number(process.env.SHARE_AI_MAX_OUTPUT_TOKENS);
|
||||
return Number.isFinite(raw) && raw > 0
|
||||
? Math.floor(raw)
|
||||
: SHARE_AI_MAX_OUTPUT_TOKENS;
|
||||
: SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,8 +223,8 @@ export class PublicShareChatService {
|
||||
tools,
|
||||
// Bound the agent loop for anonymous callers.
|
||||
stopWhen: stepCountIs(5),
|
||||
// Bounds per-request output so one anonymous request can't run up the
|
||||
// provider bill; worst case = steps x this.
|
||||
// Cap per-request output so one anonymous call cannot run up the provider
|
||||
// bill even if the per-IP throttle is evaded; worst case = steps × this.
|
||||
maxOutputTokens: resolveShareAiMaxOutputTokens(),
|
||||
abortSignal: signal,
|
||||
onError: ({ error }) => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { buildShareSystemPrompt } from './public-share-chat.prompt';
|
||||
import {
|
||||
PublicShareChatService,
|
||||
filterShareTranscript,
|
||||
resolveShareAiMaxOutputTokens,
|
||||
SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT,
|
||||
} from './public-share-chat.service';
|
||||
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
|
||||
import {
|
||||
@@ -400,6 +402,44 @@ describe('resolveShareAiWorkspaceMax (env-overridable per-workspace cap)', () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveShareAiMaxOutputTokens (env-overridable per-request output cap)', () => {
|
||||
const ENV = 'SHARE_AI_MAX_OUTPUT_TOKENS';
|
||||
const original = process.env[ENV];
|
||||
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = original;
|
||||
});
|
||||
|
||||
it('falls back to the default when unset', () => {
|
||||
delete process.env[ENV];
|
||||
expect(resolveShareAiMaxOutputTokens()).toBe(
|
||||
SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT,
|
||||
);
|
||||
expect(SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT).toBe(512);
|
||||
});
|
||||
|
||||
it('uses (and floors) a valid positive value from the env', () => {
|
||||
process.env[ENV] = '1024.9';
|
||||
expect(resolveShareAiMaxOutputTokens()).toBe(1024);
|
||||
});
|
||||
|
||||
it('falls back to the default for zero, a negative, or a non-numeric value', () => {
|
||||
process.env[ENV] = '0';
|
||||
expect(resolveShareAiMaxOutputTokens()).toBe(
|
||||
SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT,
|
||||
);
|
||||
process.env[ENV] = '-5';
|
||||
expect(resolveShareAiMaxOutputTokens()).toBe(
|
||||
SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT,
|
||||
);
|
||||
process.env[ENV] = 'not-a-number';
|
||||
expect(resolveShareAiMaxOutputTokens()).toBe(
|
||||
SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace cap)', () => {
|
||||
it('allows up to the cap within a window, then 429s (returns false)', async () => {
|
||||
const limiter = makeLimiter(3, 60_000, () => 1_000);
|
||||
@@ -482,9 +522,11 @@ describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace
|
||||
});
|
||||
|
||||
it('FAILS CLOSED (returns false) when the Redis eval rejects', async () => {
|
||||
// FAIL CLOSED (#62): if Redis is down we cannot prove the workspace is under
|
||||
// its cap, so DENY (the controller 429s) rather than admit an unmetered,
|
||||
// billable anonymous call. The feature is optional, so denial is harmless.
|
||||
// The per-workspace cap is the COST backstop for an OPTIONAL anonymous
|
||||
// assistant. If Redis is unavailable we cannot prove the workspace is under
|
||||
// its cap, so we DENY (controller 429s) rather than admit an unmetered,
|
||||
// billable call — a brief Redis blip disabling the assistant is safer than
|
||||
// an unbounded provider bill.
|
||||
const failingRedis = {
|
||||
eval: () => Promise.reject(new Error('redis down')),
|
||||
} as unknown as import('ioredis').Redis;
|
||||
|
||||
@@ -99,11 +99,11 @@ export class PublicShareWorkspaceLimiter {
|
||||
/**
|
||||
* Account one call for `key`. Returns true if it is within the cap (allowed),
|
||||
* false if the cap over the trailing window is exceeded (caller must 429).
|
||||
* On a Redis failure we FAIL CLOSED (return false): if Redis is down we cannot
|
||||
* prove the workspace is under its cap, so we DENY rather than admit an
|
||||
* unmetered, billable anonymous call. The feature is optional, so the
|
||||
* temporary denial is harmless. (Operators wanting a tighter steady-state cap
|
||||
* can lower the default via SHARE_AI_WORKSPACE_MAX_PER_HOUR, e.g. =100.)
|
||||
* On a Redis failure we FAIL CLOSED (return false): this cap is the COST
|
||||
* backstop for an OPTIONAL anonymous assistant, so when Redis is unavailable we
|
||||
* cannot prove the workspace is under its cap and therefore DENY rather than
|
||||
* admit an unmetered, billable anonymous call. A transient Redis blip briefly
|
||||
* disabling the assistant is preferable to an unbounded provider bill.
|
||||
*/
|
||||
async tryConsume(key: string): Promise<boolean> {
|
||||
const t = this.now();
|
||||
@@ -122,9 +122,11 @@ export class PublicShareWorkspaceLimiter {
|
||||
);
|
||||
return admitted === 1;
|
||||
} catch (err) {
|
||||
// FAIL CLOSED: if Redis is down we cannot prove the workspace is under its
|
||||
// cap, so DENY (controller 429s) rather than admit an unmetered, billable
|
||||
// anonymous call. The feature is optional, so denial is harmless.
|
||||
// FAIL CLOSED: when Redis is unavailable we cannot prove the workspace is
|
||||
// under its cap, so we DENY (the controller 429s) rather than admit an
|
||||
// unmetered, billable anonymous call. The assistant is optional, so a
|
||||
// transient Redis blip briefly disabling it is the safer failure mode than
|
||||
// an unbounded provider bill.
|
||||
this.logger.error(
|
||||
`share-ai workspace limiter Redis failure for key "${key}"; failing closed`,
|
||||
err as Error,
|
||||
|
||||
@@ -10,7 +10,6 @@ describe('PageController', () => {
|
||||
controller = new PageController(
|
||||
{} as any, // pageService
|
||||
{} as any, // pageRepo
|
||||
{} as any, // workspaceRepo
|
||||
{} as any, // pageHistoryService
|
||||
{} as any, // spaceAbility
|
||||
{} as any, // pageAccessService
|
||||
|
||||
@@ -39,11 +39,6 @@ import {
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import {
|
||||
isHtmlEmbedFeatureEnabled,
|
||||
stripHtmlEmbedNodes,
|
||||
} from '../../common/helpers/prosemirror/html-embed.util';
|
||||
import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
@@ -68,7 +63,6 @@ export class PageController {
|
||||
constructor(
|
||||
private readonly pageService: PageService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@@ -98,18 +92,6 @@ export class PageController {
|
||||
|
||||
const permissions = { canEdit, hasRestriction };
|
||||
|
||||
if (page.content) {
|
||||
const workspace = await this.workspaceRepo.findById(page.workspaceId);
|
||||
if (!isHtmlEmbedFeatureEnabled(workspace?.settings)) {
|
||||
// Kill-switch: when the workspace feature is OFF, never serve raw
|
||||
// htmlEmbed nodes on the read path (mirrors the public-share strip),
|
||||
// so disabling the feature is an immediate, total kill-switch and not
|
||||
// dependent on the page being re-saved. Admin-authored content only.
|
||||
// Fail-closed: a missing workspace resolves to OFF and is stripped.
|
||||
page.content = stripHtmlEmbedNodes(page.content) as any;
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.format && dto.format !== 'json' && page.content) {
|
||||
const contentOutput =
|
||||
dto.format === 'markdown'
|
||||
@@ -255,9 +237,6 @@ export class PageController {
|
||||
user.id,
|
||||
workspace.id,
|
||||
createPageDto,
|
||||
// Pass the caller's workspace role so create() can enforce the htmlEmbed
|
||||
// admin gate (non-admins cannot author raw-JS embeds).
|
||||
user.role,
|
||||
provenance,
|
||||
);
|
||||
|
||||
@@ -554,16 +533,6 @@ export class PageController {
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
if (history.content) {
|
||||
const workspace = await this.workspaceRepo.findById(page.workspaceId);
|
||||
if (!isHtmlEmbedFeatureEnabled(workspace?.settings)) {
|
||||
// Kill-switch: history snapshots are an authenticated read path too, so
|
||||
// strip htmlEmbed when the workspace feature is OFF (same as /info and
|
||||
// the public-share path). Fail-closed on a missing workspace.
|
||||
history.content = stripHtmlEmbedNodes(history.content) as any;
|
||||
}
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
// Exercises the REAL PageService htmlEmbed admin gate on its two non-collab
|
||||
// write paths: PageService.create() and PageService.duplicatePage(). Both build
|
||||
// content/textContent/ydoc directly and persist, bypassing the collab
|
||||
// onStoreDocument strip, so each must run the incoming document through the
|
||||
// toggle-AND-admin gate (`htmlEmbedAllowed(featureEnabled, role)` -> if not
|
||||
// allowed, `stripHtmlEmbedNodes`) BEFORE persisting.
|
||||
//
|
||||
// This spec constructs the REAL PageService with every constructor dep mocked,
|
||||
// feeds content containing an `htmlEmbed`, calls the real method, and asserts on
|
||||
// the PERSISTED content (captured at the repo insert / db insert boundary) that
|
||||
// the embed was actually stripped (member/unknown role) or preserved
|
||||
// (admin/owner + toggle ON). Mirrors the GOOD pattern in
|
||||
// transclusion/spec/transclusion-unsync-html-embed.spec.ts.
|
||||
//
|
||||
// page.service.ts pulls in the collaboration gateway (a transitive ESM chain
|
||||
// `lib0/decoding.js` that jest's transformIgnorePatterns does not transpile), so
|
||||
// that single module is mocked away — it is never used on the create/duplicate
|
||||
// gate paths.
|
||||
jest.mock('../../../collaboration/collaboration.gateway', () => ({
|
||||
CollaborationGateway: class {},
|
||||
}));
|
||||
|
||||
import { PageService } from './page.service';
|
||||
import { hasHtmlEmbedNode } from '../../../common/helpers/prosemirror/html-embed.util';
|
||||
|
||||
const WS = 'ws-1';
|
||||
const SPACE = 'space-1';
|
||||
const USER = 'u1';
|
||||
|
||||
const docWithEmbed = () => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'body' }] },
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>alert(1)</script>' } },
|
||||
],
|
||||
});
|
||||
|
||||
// Minimal chainable kysely stub. `nextPagePosition` (used by create) and
|
||||
// duplicatePage's bulk insert go through `this.db`; only the calls those paths
|
||||
// make need to resolve. `capturedInserts` collects every page row handed to
|
||||
// `insertInto('pages').values(...)` so we can assert on the persisted content.
|
||||
function buildDb(capturedInserts: any[]) {
|
||||
const selectChain: any = {
|
||||
select: () => selectChain,
|
||||
selectAll: () => selectChain,
|
||||
where: () => selectChain,
|
||||
orderBy: () => selectChain,
|
||||
limit: () => selectChain,
|
||||
execute: async () => [],
|
||||
executeTakeFirst: async () => undefined,
|
||||
};
|
||||
const db: any = {
|
||||
selectFrom: () => selectChain,
|
||||
insertInto: (table: string) => ({
|
||||
values: (rows: any) => {
|
||||
if (table === 'pages') {
|
||||
for (const row of Array.isArray(rows) ? rows : [rows]) {
|
||||
capturedInserts.push(row);
|
||||
}
|
||||
}
|
||||
return { execute: async () => undefined };
|
||||
},
|
||||
}),
|
||||
// executeTx -> db.transaction().execute(cb): run the callback with `db`
|
||||
// itself acting as the transaction so any in-tx inserts are captured too.
|
||||
transaction: () => ({ execute: async (cb: any) => cb(db) }),
|
||||
};
|
||||
return db;
|
||||
}
|
||||
|
||||
// Build the REAL PageService with all 13 constructor deps mocked. `featureEnabled`
|
||||
// drives the workspace toggle the gate reads via workspaceRepo.findById.
|
||||
function buildService(opts: {
|
||||
featureEnabled: boolean;
|
||||
capturedInserts: any[];
|
||||
rootPage?: any; // for duplicatePage
|
||||
}) {
|
||||
const { featureEnabled, capturedInserts } = opts;
|
||||
|
||||
const pageRepo: any = {
|
||||
findById: jest.fn(async () => null), // no parent page in create tests
|
||||
// create() persists here; capture the row so we can inspect content.
|
||||
insertPage: jest.fn(async (row: any) => {
|
||||
capturedInserts.push(row);
|
||||
return { id: 'new-page', slugId: 'slug-1', ...row };
|
||||
}),
|
||||
getPageAndDescendants: jest.fn(async () => [opts.rootPage].filter(Boolean)),
|
||||
};
|
||||
|
||||
const pagePermissionRepo: any = {
|
||||
// duplicatePage filters accessible pages; grant the root so it is copied.
|
||||
filterAccessiblePageIds: jest.fn(async () =>
|
||||
opts.rootPage ? [opts.rootPage.id] : [],
|
||||
),
|
||||
};
|
||||
|
||||
const workspaceRepo: any = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: WS,
|
||||
settings: { htmlEmbed: featureEnabled },
|
||||
})),
|
||||
};
|
||||
|
||||
const attachmentRepo: any = { findByIds: jest.fn(async () => []) };
|
||||
const storageService: any = { copy: jest.fn(async () => undefined) };
|
||||
const noopQueue: any = { add: jest.fn(async () => undefined) };
|
||||
const eventEmitter: any = { emit: jest.fn() };
|
||||
const collaborationGateway: any = {};
|
||||
const watcherService: any = {};
|
||||
// duplicatePage fires transclusion bulk inserts after persisting; they are
|
||||
// best-effort (wrapped in try/catch) and irrelevant to the gate.
|
||||
const transclusionService: any = {
|
||||
insertTransclusionsForPages: jest.fn(async () => undefined),
|
||||
insertReferencesForPages: jest.fn(async () => undefined),
|
||||
insertTemplateReferencesForPages: jest.fn(async () => undefined),
|
||||
};
|
||||
|
||||
const db = buildDb(capturedInserts);
|
||||
|
||||
const service = new PageService(
|
||||
pageRepo,
|
||||
pagePermissionRepo,
|
||||
attachmentRepo,
|
||||
db,
|
||||
storageService,
|
||||
noopQueue, // attachmentQueue
|
||||
noopQueue, // aiQueue
|
||||
noopQueue, // generalQueue
|
||||
eventEmitter,
|
||||
collaborationGateway,
|
||||
watcherService,
|
||||
transclusionService,
|
||||
workspaceRepo,
|
||||
);
|
||||
return service;
|
||||
}
|
||||
|
||||
describe('PageService.create htmlEmbed admin gate (real code)', () => {
|
||||
// Run create() and return the content actually persisted via insertPage.
|
||||
async function persistedContent(
|
||||
featureEnabled: boolean,
|
||||
callerRole: string | null | undefined,
|
||||
) {
|
||||
const capturedInserts: any[] = [];
|
||||
const service = buildService({ featureEnabled, capturedInserts });
|
||||
await service.create(
|
||||
USER,
|
||||
WS,
|
||||
{
|
||||
spaceId: SPACE,
|
||||
title: 'p',
|
||||
// 'json' format is used as-is by parseProsemirrorContent (passed to the
|
||||
// real jsonToNode schema validation), so hand it the PM-JSON object.
|
||||
content: docWithEmbed(),
|
||||
format: 'json' as any,
|
||||
} as any,
|
||||
callerRole,
|
||||
);
|
||||
expect(capturedInserts).toHaveLength(1);
|
||||
return capturedInserts[0].content;
|
||||
}
|
||||
|
||||
it('toggle ON + member: persisted content has htmlEmbed stripped', async () => {
|
||||
const content = await persistedContent(true, 'member');
|
||||
expect(hasHtmlEmbedNode(content)).toBe(false);
|
||||
// Non-embed content survives.
|
||||
expect(JSON.stringify(content)).toContain('body');
|
||||
});
|
||||
|
||||
it('toggle ON + admin: persisted content keeps the htmlEmbed', async () => {
|
||||
expect(hasHtmlEmbedNode(await persistedContent(true, 'admin'))).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle ON + owner: persisted content keeps the htmlEmbed', async () => {
|
||||
expect(hasHtmlEmbedNode(await persistedContent(true, 'owner'))).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
|
||||
expect(hasHtmlEmbedNode(await persistedContent(false, 'admin'))).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown/empty role: fails closed (stripped)', async () => {
|
||||
for (const role of [undefined, null, 'viewer'] as const) {
|
||||
expect(hasHtmlEmbedNode(await persistedContent(true, role))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageService.duplicatePage htmlEmbed admin gate (real code)', () => {
|
||||
// Duplicate a single source page that contains an embed and return the content
|
||||
// persisted for the copy (captured at db.insertInto('pages').values(...)).
|
||||
async function persistedContent(
|
||||
featureEnabled: boolean,
|
||||
role: string | null | undefined,
|
||||
) {
|
||||
const rootPage: any = {
|
||||
id: 'src-page',
|
||||
slugId: 'src-slug',
|
||||
title: 'Source',
|
||||
icon: null,
|
||||
position: 'a0',
|
||||
spaceId: SPACE,
|
||||
workspaceId: WS,
|
||||
parentPageId: null,
|
||||
content: docWithEmbed(),
|
||||
};
|
||||
const capturedInserts: any[] = [];
|
||||
const service = buildService({ featureEnabled, capturedInserts, rootPage });
|
||||
const authUser: any = { id: USER, workspaceId: WS, role };
|
||||
await service.duplicatePage(rootPage, undefined, authUser);
|
||||
// The bulk insert is the page persist boundary; one source page -> one copy.
|
||||
const pageRows = capturedInserts.filter((r) => r.content);
|
||||
expect(pageRows.length).toBeGreaterThanOrEqual(1);
|
||||
return pageRows[0].content;
|
||||
}
|
||||
|
||||
it('toggle ON + member: persisted copy has htmlEmbed stripped', async () => {
|
||||
const content = await persistedContent(true, 'member');
|
||||
expect(hasHtmlEmbedNode(content)).toBe(false);
|
||||
expect(JSON.stringify(content)).toContain('body');
|
||||
});
|
||||
|
||||
it('toggle ON + admin: persisted copy keeps the htmlEmbed', async () => {
|
||||
expect(hasHtmlEmbedNode(await persistedContent(true, 'admin'))).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle ON + owner: persisted copy keeps the htmlEmbed', async () => {
|
||||
expect(hasHtmlEmbedNode(await persistedContent(true, 'owner'))).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
|
||||
expect(hasHtmlEmbedNode(await persistedContent(false, 'admin'))).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown/empty role: fails closed (stripped)', async () => {
|
||||
for (const role of [undefined, null, 'viewer'] as const) {
|
||||
expect(hasHtmlEmbedNode(await persistedContent(true, role))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ describe('PageService', () => {
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,11 +31,6 @@ import {
|
||||
isAttachmentNode,
|
||||
removeMarkTypeFromDoc,
|
||||
} from '../../../common/helpers/prosemirror/utils';
|
||||
import {
|
||||
isHtmlEmbedFeatureEnabled,
|
||||
stripHtmlEmbedIfNotAllowed,
|
||||
} from '../../../common/helpers/prosemirror/html-embed.util';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import {
|
||||
htmlToJson,
|
||||
jsonToNode,
|
||||
@@ -81,7 +76,6 @@ export class PageService {
|
||||
private collaborationGateway: CollaborationGateway,
|
||||
private readonly watcherService: WatcherService,
|
||||
private readonly transclusionService: TransclusionService,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
@@ -101,10 +95,6 @@ export class PageService {
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
createPageDto: CreatePageDto,
|
||||
// Workspace role of the caller. Used to enforce the htmlEmbed admin gate on
|
||||
// the create write path (see below). Optional/typed loosely so unknown or
|
||||
// missing roles fall through to the non-admin (strip) branch by default.
|
||||
callerRole?: string | null,
|
||||
// Optional agent-edit provenance (from the signed access claim). When the
|
||||
// actor is 'agent', stamp the page's source marker so a freshly created page
|
||||
// shows it was created by the AI agent (§14 N2) — create goes through REST,
|
||||
@@ -135,35 +125,11 @@ export class PageService {
|
||||
let ydoc = undefined;
|
||||
|
||||
if (createPageDto?.content && createPageDto?.format) {
|
||||
let prosemirrorJson = await this.parseProsemirrorContent(
|
||||
const prosemirrorJson = await this.parseProsemirrorContent(
|
||||
createPageDto.content,
|
||||
createPageDto.format,
|
||||
);
|
||||
|
||||
// SECURITY (Variant C admin gate, plain page-create write path):
|
||||
// create() builds content/textContent/ydoc directly and persists them via
|
||||
// insertPage, bypassing the collab onStoreDocument strip. htmlEmbed renders
|
||||
// raw, unsanitized JS in readers' browsers, so only workspace admins/owners
|
||||
// may author it. The create controller requires only space Edit, so a
|
||||
// regular member could otherwise POST a doc (json, or the markdown/html
|
||||
// <!--html-embed:BASE64--> forms that parse to the same node) containing an
|
||||
// htmlEmbed and store XSS for every reader. Strip every htmlEmbed node when
|
||||
// the caller is not an admin, BEFORE deriving textContent/ydoc/insert.
|
||||
// The gate is toggle-AND-admin: htmlEmbed survives only when the workspace
|
||||
// feature toggle is ON and the caller is an admin/owner. OFF (default) =>
|
||||
// stripped for everyone. Cheap settings read keyed to the workspace.
|
||||
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
|
||||
(await this.workspaceRepo.findById(workspaceId))?.settings,
|
||||
);
|
||||
prosemirrorJson = stripHtmlEmbedIfNotAllowed(prosemirrorJson, {
|
||||
featureEnabled: htmlEmbedEnabled,
|
||||
role: callerRole,
|
||||
onStrip: () =>
|
||||
this.logger.warn(
|
||||
`Stripping htmlEmbed node(s) from page creation by user ${userId} (space ${createPageDto.spaceId})`,
|
||||
),
|
||||
});
|
||||
|
||||
content = prosemirrorJson;
|
||||
textContent = jsonToText(prosemirrorJson);
|
||||
ydoc = createYdocFromJson(prosemirrorJson);
|
||||
@@ -653,12 +619,6 @@ export class PageService {
|
||||
|
||||
const attachmentMap = new Map<string, ICopyPageAttachment>();
|
||||
|
||||
// Resolve the htmlEmbed toggle ONCE for the workspace; the per-page gate
|
||||
// below is toggle-AND-admin (OFF default => stripped for everyone).
|
||||
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
|
||||
(await this.workspaceRepo.findById(rootPage.workspaceId))?.settings,
|
||||
);
|
||||
|
||||
const insertablePages: InsertablePage[] = await Promise.all(
|
||||
pages.map(async (page) => {
|
||||
const pageContent = getProsemirrorContent(page.content);
|
||||
@@ -769,24 +729,7 @@ export class PageService {
|
||||
}
|
||||
});
|
||||
|
||||
let prosemirrorJson = prosemirrorDoc.toJSON();
|
||||
|
||||
// SECURITY (Variant C admin gate, duplication write path):
|
||||
// Duplication builds the ydoc directly and bypasses the collab
|
||||
// onStoreDocument strip. htmlEmbed renders raw, unsanitized JS in
|
||||
// readers' browsers, so only workspace admins/owners may author it. A
|
||||
// non-admin with space Edit could otherwise duplicate an admin page
|
||||
// that contains an embed into a new page authored by them. Strip every
|
||||
// htmlEmbed node from each duplicated page when the duplicating user is
|
||||
// not an admin, BEFORE computing textContent/ydoc/insert.
|
||||
prosemirrorJson = stripHtmlEmbedIfNotAllowed(prosemirrorJson, {
|
||||
featureEnabled: htmlEmbedEnabled,
|
||||
role: authUser.role,
|
||||
onStrip: () =>
|
||||
this.logger.warn(
|
||||
`Stripping htmlEmbed node(s) from page duplication by user ${authUser.id} (source page ${page.id})`,
|
||||
),
|
||||
});
|
||||
const prosemirrorJson = prosemirrorDoc.toJSON();
|
||||
|
||||
// Add "Copy of " prefix to the root page title only for duplicates in same space
|
||||
let title = page.title;
|
||||
|
||||
@@ -68,7 +68,6 @@ describe('TransclusionService — template access core (real filter)', () => {
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
return { service, db, pageRepo, spaceMemberRepo, pagePermissionRepo };
|
||||
@@ -227,7 +226,6 @@ describe('TransclusionService.filterViewerAccessiblePageIds — AND ordering (co
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
return { service, filterAccessiblePageIds };
|
||||
@@ -324,7 +322,6 @@ describe('TransclusionService.syncPageTemplateReferences — workspace scoping',
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -471,7 +468,6 @@ describe('TransclusionService.insertTemplateReferencesForPages — per-workspace
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
return { service, insertMany };
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ describe('TransclusionService.lookupTemplate — anti-leak catch branch', () =>
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
// Stub the access decision; we are testing the content-prep stage, not access.
|
||||
@@ -158,7 +157,6 @@ describe('TransclusionService.lookupTemplate — soft-deleted source via real fi
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
const { items } = await service.lookupTemplate(['deleted-src'], 'u1', 'w1');
|
||||
|
||||
@@ -35,7 +35,6 @@ describe('TransclusionService.lookupTemplate (access mapping)', () => {
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -57,7 +57,6 @@ function buildService(opts: {
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import { hasHtmlEmbedNode } from '../../../../common/helpers/prosemirror/html-embed.util';
|
||||
|
||||
// Exercises the REAL TransclusionService.unsyncReference htmlEmbed admin gate.
|
||||
// unsync returns a source snapshot the client materializes into the reference
|
||||
// page; a non-admin must never receive an embed payload to re-persist. The gate
|
||||
// reads `user.role` and strips before returning. All repos / access checks are
|
||||
// mocked so the REAL gate logic runs end-to-end. Complements the existing
|
||||
// transclusion specs (rewriteAttachmentsForUnsync, controller).
|
||||
|
||||
const WS = 'ws-1';
|
||||
const REF_PAGE = 'ref-1';
|
||||
const SRC_PAGE = 'src-1';
|
||||
const TX_ID = 'tx-1';
|
||||
|
||||
const sourceContentWithEmbed = () => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'snapshot body' }] },
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>steal()</script>' } },
|
||||
],
|
||||
});
|
||||
|
||||
function buildService(featureEnabled = true) {
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
workspaceId: WS,
|
||||
spaceId: 'space-1',
|
||||
deletedAt: null,
|
||||
})),
|
||||
};
|
||||
const pageTransclusionsRepo = {
|
||||
findByPageAndTransclusion: jest.fn(async () => ({
|
||||
content: sourceContentWithEmbed(),
|
||||
})),
|
||||
};
|
||||
const pageTransclusionReferencesRepo = {
|
||||
deleteOne: jest.fn(async () => undefined),
|
||||
};
|
||||
const attachmentRepo = { findByIds: jest.fn(async () => []) };
|
||||
const storageService = { copy: jest.fn(async () => undefined) };
|
||||
const pageAccessService = {
|
||||
validateCanEdit: jest.fn(async () => undefined),
|
||||
validateCanView: jest.fn(async () => undefined),
|
||||
};
|
||||
// Workspace settings read used by the toggle-AND-admin gate.
|
||||
const workspaceRepo = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: WS,
|
||||
settings: { htmlEmbed: featureEnabled },
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db (unused on this path)
|
||||
pageTransclusionsRepo as any,
|
||||
pageTransclusionReferencesRepo as any,
|
||||
{} as any, // pageTemplateReferencesRepo (unused on this path)
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo (unused)
|
||||
{} as any, // spaceMemberRepo (unused)
|
||||
attachmentRepo as any,
|
||||
storageService as any,
|
||||
pageAccessService as any,
|
||||
workspaceRepo as any,
|
||||
);
|
||||
return service;
|
||||
}
|
||||
|
||||
function userWithRole(role: string | null | undefined) {
|
||||
return { id: 'u1', workspaceId: WS, role } as any;
|
||||
}
|
||||
|
||||
describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)', () => {
|
||||
it('non-admin (member): returned content has htmlEmbed stripped', async () => {
|
||||
const service = buildService();
|
||||
const { content } = await service.unsyncReference(
|
||||
REF_PAGE,
|
||||
SRC_PAGE,
|
||||
TX_ID,
|
||||
userWithRole('member'),
|
||||
);
|
||||
expect(hasHtmlEmbedNode(content)).toBe(false);
|
||||
// Non-embed content is preserved.
|
||||
expect(JSON.stringify(content)).toContain('snapshot body');
|
||||
});
|
||||
|
||||
it('unknown/empty role: fails closed (stripped)', async () => {
|
||||
for (const role of [undefined, null, 'viewer'] as const) {
|
||||
const service = buildService();
|
||||
const { content } = await service.unsyncReference(
|
||||
REF_PAGE,
|
||||
SRC_PAGE,
|
||||
TX_ID,
|
||||
userWithRole(role),
|
||||
);
|
||||
expect(hasHtmlEmbedNode(content)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('toggle ON + admin: returned content keeps the htmlEmbed', async () => {
|
||||
const service = buildService(true);
|
||||
const { content } = await service.unsyncReference(
|
||||
REF_PAGE,
|
||||
SRC_PAGE,
|
||||
TX_ID,
|
||||
userWithRole('admin'),
|
||||
);
|
||||
expect(hasHtmlEmbedNode(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle ON + owner: returned content keeps the htmlEmbed', async () => {
|
||||
const service = buildService(true);
|
||||
const { content } = await service.unsyncReference(
|
||||
REF_PAGE,
|
||||
SRC_PAGE,
|
||||
TX_ID,
|
||||
userWithRole('owner'),
|
||||
);
|
||||
expect(hasHtmlEmbedNode(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
|
||||
const service = buildService(false);
|
||||
const { content } = await service.unsyncReference(
|
||||
REF_PAGE,
|
||||
SRC_PAGE,
|
||||
TX_ID,
|
||||
userWithRole('admin'),
|
||||
);
|
||||
expect(hasHtmlEmbedNode(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('toggle OFF + member: stripped', async () => {
|
||||
const service = buildService(false);
|
||||
const { content } = await service.unsyncReference(
|
||||
REF_PAGE,
|
||||
SRC_PAGE,
|
||||
TX_ID,
|
||||
userWithRole('member'),
|
||||
);
|
||||
expect(hasHtmlEmbedNode(content)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -33,11 +33,6 @@ import {
|
||||
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
||||
import { Page, User } from '@docmost/db/types/entity.types';
|
||||
import { PageAccessService } from '../page-access/page-access.service';
|
||||
import {
|
||||
isHtmlEmbedFeatureEnabled,
|
||||
stripHtmlEmbedIfNotAllowed,
|
||||
} from '../../../common/helpers/prosemirror/html-embed.util';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
|
||||
type ReferencingPageInfo = {
|
||||
id: string;
|
||||
@@ -63,7 +58,6 @@ export class TransclusionService {
|
||||
private readonly attachmentRepo: AttachmentRepo,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
) {}
|
||||
|
||||
async syncPageTransclusions(
|
||||
@@ -773,26 +767,6 @@ export class TransclusionService {
|
||||
transclusionId,
|
||||
);
|
||||
|
||||
// SECURITY (Variant C admin gate, transclusion unsync write path):
|
||||
// The returned content is a source snapshot that the client materializes
|
||||
// into the reference page via insertContentAt. The snapshot keeps any
|
||||
// htmlEmbed verbatim, and unsync requires only space Edit/View. If the
|
||||
// requesting user is not a workspace admin/owner, strip htmlEmbed nodes so a
|
||||
// non-admin can never receive an embed payload to re-persist (the collab
|
||||
// strip on the subsequent save is debounced/race-prone and must not be the
|
||||
// only guard). Admin behavior is unchanged.
|
||||
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
|
||||
(await this.workspaceRepo.findById(user.workspaceId))?.settings,
|
||||
);
|
||||
content = stripHtmlEmbedIfNotAllowed(content, {
|
||||
featureEnabled: htmlEmbedEnabled,
|
||||
role: user.role,
|
||||
onStrip: () =>
|
||||
this.logger.warn(
|
||||
`Stripping htmlEmbed node(s) from transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`,
|
||||
),
|
||||
});
|
||||
|
||||
return { content };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { ShareService } from './share.service';
|
||||
import { hasHtmlEmbedNode } from '../../common/helpers/prosemirror/html-embed.util';
|
||||
|
||||
// Exercises the REAL ShareService server-authoritative htmlEmbed kill-switch for
|
||||
// shared content. An anonymous public-share viewer cannot read the per-workspace
|
||||
// htmlEmbed toggle, so the SERVER must decide what to serve: when the toggle is
|
||||
// OFF, htmlEmbed nodes are stripped from the shared doc; when ON they are kept so
|
||||
// the read-only client executes them. All repos / token service are mocked so the
|
||||
// real prepareContentForShare logic runs end-to-end via getSharedPage.
|
||||
// Exercises the REAL ShareService server-authoritative htmlEmbed master toggle
|
||||
// for shared content. The block renders inside a sandboxed iframe (harmless), so
|
||||
// this is NOT an XSS guard — it is the master-toggle enforcement for anonymous
|
||||
// shares: an anonymous public-share viewer cannot read the per-workspace
|
||||
// htmlEmbed toggle, so the SERVER must decide what to serve. When the toggle is
|
||||
// OFF, htmlEmbed nodes are stripped from the shared doc; when ON they are served
|
||||
// and rendered in their sandboxed frame. All repos / token service are mocked so
|
||||
// the real prepareContentForShare logic runs end-to-end via getSharedPage.
|
||||
|
||||
const WS = 'ws-1';
|
||||
const PAGE = 'page-1';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
||||
import { Controller, Get, Logger, Param, Req, Res } from '@nestjs/common';
|
||||
import { ShareService } from './share.service';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { join } from 'path';
|
||||
@@ -11,6 +11,8 @@ import { htmlEscape } from '../../common/helpers/html-escaper';
|
||||
|
||||
@Controller('share')
|
||||
export class ShareSeoController {
|
||||
private readonly logger = new Logger(ShareSeoController.name);
|
||||
|
||||
constructor(
|
||||
private readonly shareService: ShareService,
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
@@ -84,10 +86,34 @@ export class ShareSeoController {
|
||||
.join('\n ');
|
||||
|
||||
const html = fs.readFileSync(indexFilePath, 'utf8');
|
||||
const transformedHtml = html
|
||||
let transformedHtml = html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${metaTitle}</title>`)
|
||||
.replace(metaTagVar, metaTags);
|
||||
|
||||
// Deliberate same-origin tracker surface: this is the ONE place where an
|
||||
// admin-authored analytics/tracker snippet (settings.trackerHead) is
|
||||
// injected verbatim into the page origin. It is admin-only (writable only
|
||||
// via the admin-gated workspace settings) and applies to PUBLIC SHARE
|
||||
// pages only. It is trusted content, so it is NOT escaped. The htmlEmbed
|
||||
// block itself is sandboxed and is the safe surface for everyone else.
|
||||
const trackerHead = (workspace?.settings as any)?.trackerHead;
|
||||
if (typeof trackerHead === 'string' && trackerHead.trim().length > 0) {
|
||||
if (transformedHtml.includes('</head>')) {
|
||||
// Function replacer: the snippet is admin-authored trusted content and
|
||||
// must be injected verbatim. A string replacement would interpret `$&`,
|
||||
// `$'`, `` $` `` and `$$` inside it as substitution patterns and mangle
|
||||
// the tracker; a function return value is inserted literally.
|
||||
transformedHtml = transformedHtml.replace(
|
||||
'</head>',
|
||||
() => `${trackerHead}\n</head>`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
'trackerHead is configured but no </head> marker was found in the share index HTML; tracker snippet was not injected.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.type('text/html').send(transformedHtml);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +87,16 @@ export class ShareController {
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
// Resolve the identity name only when the assistant is enabled, so the
|
||||
// anonymous widget can label messages with the configured persona name.
|
||||
const aiAssistantName = aiAssistant
|
||||
? await this.aiSettings.resolvePublicShareAssistantName(workspace.id)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...shareData,
|
||||
aiAssistant,
|
||||
aiAssistantName,
|
||||
features: this.licenseCheckService.resolveFeatures(
|
||||
workspace.licenseKey,
|
||||
workspace.plan,
|
||||
|
||||
@@ -524,12 +524,14 @@ export class ShareService {
|
||||
* not leak structure (existence, location, count, resolved state, or
|
||||
* comment ids) to public viewers.
|
||||
*
|
||||
* 3. Strip `htmlEmbed` nodes when the workspace feature toggle is OFF. This
|
||||
* makes the toggle a SERVER-AUTHORITATIVE kill-switch for shared content:
|
||||
* when OFF the embed is never served to the anonymous viewer (who can't
|
||||
* read the per-workspace toggle), when ON the embed is served so the
|
||||
* read-only client executes it. `htmlEmbedEnabled` is resolved fail-closed
|
||||
* by the callers (missing workspace => OFF => strip).
|
||||
* 3. Strip `htmlEmbed` nodes when the workspace master toggle is OFF. The
|
||||
* block renders inside a sandboxed iframe on the client (harmless, no
|
||||
* same-origin access), so this is NOT an XSS guard — it is the
|
||||
* SERVER-AUTHORITATIVE enforcement of the workspace master toggle for
|
||||
* anonymous shares: an anonymous viewer cannot read the per-workspace
|
||||
* toggle, so when OFF the block is never served, and when ON it is served
|
||||
* and rendered in its sandboxed frame. `htmlEmbedEnabled` is resolved
|
||||
* fail-closed by the callers (missing workspace => OFF => strip).
|
||||
*
|
||||
* Both share-content paths — the host page (`updatePublicAttachments`) and
|
||||
* the share-scoped transclusion lookup (`lookupTransclusionForShare`) —
|
||||
@@ -544,8 +546,9 @@ export class ShareService {
|
||||
): Promise<Node | null> {
|
||||
let pmJson = getProsemirrorContent(content);
|
||||
|
||||
// Kill-switch: when the workspace toggle is OFF, never serve htmlEmbed
|
||||
// nodes to public viewers. Strip before tokenizing/serializing.
|
||||
// Master-toggle enforcement: when the workspace toggle is OFF, never serve
|
||||
// htmlEmbed nodes to anonymous public viewers (who cannot read the toggle).
|
||||
// Strip before tokenizing/serializing.
|
||||
if (!htmlEmbedEnabled) {
|
||||
pmJson = stripHtmlEmbedNodes(pmJson);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
@@ -53,12 +55,22 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsBoolean()
|
||||
aiDictation: boolean;
|
||||
|
||||
// Workspace feature toggle for the admin-only HTML embed feature. Persisted at
|
||||
// settings.htmlEmbed. ABSENT/false => OFF (default).
|
||||
// Workspace master toggle that enables/disables the HTML embed block type.
|
||||
// Persisted at settings.htmlEmbed. ABSENT/false => OFF (default). The block
|
||||
// itself renders in a sandboxed iframe, so this is a feature switch, not a
|
||||
// security gate.
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
htmlEmbed: boolean;
|
||||
|
||||
// Admin-only analytics/tracker snippet (raw HTML/JS) injected verbatim into
|
||||
// the <head> of PUBLIC SHARE pages only (same-origin). Persisted at
|
||||
// settings.trackerHead. Admin-authored trusted content.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
trackerHead?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
aiPublicShareAssistant: boolean;
|
||||
|
||||
@@ -108,4 +108,38 @@ describe('WorkspaceService.update — htmlEmbed toggle persistence (real code)',
|
||||
expect(logged.changes.before.htmlEmbed).toBe(false);
|
||||
expect(logged.changes.after.htmlEmbed).toBe(true);
|
||||
});
|
||||
|
||||
it('persists trackerHead via updateSetting with the trackerHead key', async () => {
|
||||
const { service, updateSetting } = buildService({});
|
||||
|
||||
await service.update('w1', { trackerHead: '<script>ga()</script>' } as any);
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith(
|
||||
'w1',
|
||||
'trackerHead',
|
||||
'<script>ga()</script>',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT call updateSetting when trackerHead is undefined in the dto', async () => {
|
||||
const { service, updateSetting } = buildService({});
|
||||
|
||||
await service.update('w1', { name: 'New name' } as any);
|
||||
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('audits the trackerHead change (before/after) when the value changes', async () => {
|
||||
const { service, auditService } = buildService({
|
||||
settingsBefore: { trackerHead: '' },
|
||||
});
|
||||
|
||||
await service.update('w1', { trackerHead: '<script>m()</script>' } as any);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
const logged = auditService.log.mock.calls[0][0];
|
||||
expect(logged.changes.before.trackerHead).toBe('');
|
||||
expect(logged.changes.after.trackerHead).toBe('<script>m()</script>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -525,6 +525,22 @@ export class WorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.trackerHead !== 'undefined') {
|
||||
// Admin-only analytics/tracker snippet injected into the <head> of
|
||||
// public share pages (same-origin). Persisted at settings.trackerHead.
|
||||
const prev = (settingsBefore as any)?.trackerHead ?? '';
|
||||
if (prev !== updateWorkspaceDto.trackerHead) {
|
||||
before.trackerHead = prev;
|
||||
after.trackerHead = updateWorkspaceDto.trackerHead;
|
||||
}
|
||||
await this.workspaceRepo.updateSetting(
|
||||
workspaceId,
|
||||
'trackerHead',
|
||||
updateWorkspaceDto.trackerHead,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.aiPublicShareAssistant !== 'undefined') {
|
||||
const prev = settingsBefore?.ai?.publicShareAssistant ?? false;
|
||||
if (prev !== updateWorkspaceDto.aiPublicShareAssistant) {
|
||||
@@ -549,6 +565,7 @@ export class WorkspaceService {
|
||||
delete updateWorkspaceDto.aiChat;
|
||||
delete updateWorkspaceDto.aiDictation;
|
||||
delete updateWorkspaceDto.htmlEmbed;
|
||||
delete updateWorkspaceDto.trackerHead;
|
||||
delete updateWorkspaceDto.aiPublicShareAssistant;
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(
|
||||
|
||||
Reference in New Issue
Block a user