From aa5bb5d67abac8dccd18df7064ea56d2e54b8c02 Mon Sep 17 00:00:00 2001 From: Ray Xi <2667192167@qq.com> Date: Tue, 18 Aug 2026 15:49:39 +0000 Subject: [PATCH] fix: persist public file shares across daemon restarts (#7002) * fix: persist public file shares across restarts * fix(daemon): redact unpersisted public snapshots Generated-By: looper 0.11.8 (runner=fixer, agent=codex) * fix(web): expose public share recovery action Generated-By: looper 0.11.8 (runner=fixer, agent=codex) --- apps/daemon/src/cli.ts | 39 ++- .../collab/public-file-publication-store.ts | 145 +++++++++ apps/daemon/src/db.ts | 2 + apps/daemon/src/routes/collab-sync.ts | 78 ++++- apps/daemon/src/server.ts | 2 + apps/daemon/tests/project-cli.test.ts | 50 +++ .../public-file-publication-restart.test.ts | 289 ++++++++++++++++++ .../public-file-publication-store.test.ts | 48 +++ apps/web/src/collab/public-file-publish.ts | 32 ++ apps/web/src/components/FileViewer.tsx | 25 +- apps/web/src/providers/registry.ts | 74 ++++- apps/web/tests/components/FileViewer.test.tsx | 62 ++++ packages/contracts/src/api/collab.ts | 23 ++ 13 files changed, 839 insertions(+), 30 deletions(-) create mode 100644 apps/daemon/src/collab/public-file-publication-store.ts create mode 100644 apps/daemon/tests/public-file-publication-restart.test.ts create mode 100644 apps/daemon/tests/public-file-publication-store.test.ts diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index d99616eea9..a72d3993fe 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -237,7 +237,7 @@ const MESSAGE_CENTER_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); const PROJECT_STRING_FLAGS = new Set([ 'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json', 'pending-prompt', 'project', 'conversation', 'message', 'prompt', - 'prompt-file', 'path', 'dir', 'as', + 'prompt-file', 'path', 'dir', 'as', 'url', 'agent', 'model', 'service-tier', 'snapshot-id', 'inputs', 'grant-caps', 'editor', 'title', 'label', 'against', 'seed-from', 'fork-after', 'mode', 'source', @@ -6778,6 +6778,9 @@ async function runProject(args) { od project list List projects. od project info Print one project. od project delete Delete a project. + od project revoke-public-link --path --url + Revoke a public file link whose local publication record + was lost during an older daemon restart or upgrade. od project editors List locally-installed editors that can open a project (hand-off targets). od project open-in --editor Open the project's working directory @@ -6877,6 +6880,40 @@ Common options: process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } + case 'revoke-public-link': { + const id = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; + const filePath = typeof flags.path === 'string' ? flags.path.trim() : ''; + const publicUrl = typeof flags.url === 'string' ? flags.url.trim() : ''; + let slug = ''; + try { + const parsed = new URL(publicUrl); + const match = parsed.pathname.match( + /^\/api\/v1\/public\/snapshots\/([^/]+)(?:\/|$)/u, + ); + slug = match?.[1] ? decodeURIComponent(match[1]) : ''; + } catch { + slug = ''; + } + if (!id || !filePath || !slug) { + console.error( + 'Usage: od project revoke-public-link --path --url [--json]', + ); + process.exit(2); + } + const resp = await fetch( + `${base}/api/projects/${encodeURIComponent(id)}/files/${encodeURIComponent(filePath)}/publish-public`, + { + method: 'DELETE', + headers: { 'content-type': 'application/json', ...workspaceHeaders }, + body: JSON.stringify({ slug }), + }, + ); + if (!resp.ok) return structuredHttpFailure(resp); + const data = await resp.json(); + if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + console.log(`[project] revoked public link ${slug} for ${filePath}`); + return; + } case 'create': { const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() diff --git a/apps/daemon/src/collab/public-file-publication-store.ts b/apps/daemon/src/collab/public-file-publication-store.ts new file mode 100644 index 0000000000..34c0a84182 --- /dev/null +++ b/apps/daemon/src/collab/public-file-publication-store.ts @@ -0,0 +1,145 @@ +import type Database from 'better-sqlite3'; + +type SqliteDb = Database.Database; + +export interface PublicFilePublicationScope { + resourceTeamId: string; + ownerMemberId: string; + projectId: string; + filePath: string; +} + +export interface PublicFilePublication { + url: string; + slug: string; + fileName: string; +} + +export interface PublicFilePublicationStore { + get(scope: PublicFilePublicationScope): PublicFilePublication | null; + set( + scope: PublicFilePublicationScope, + publication: PublicFilePublication, + ): void; + delete(scope: PublicFilePublicationScope): void; +} + +export function migratePublicFilePublications(db: SqliteDb): void { + db.exec(` + CREATE TABLE IF NOT EXISTS public_file_publications ( + resource_team_id TEXT NOT NULL, + owner_member_id TEXT NOT NULL, + project_id TEXT NOT NULL, + file_path TEXT NOT NULL, + url TEXT NOT NULL, + slug TEXT NOT NULL, + file_name TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (resource_team_id, owner_member_id, project_id, file_path) + ); + `); +} + +function scopeKey(scope: PublicFilePublicationScope): string { + return JSON.stringify([ + scope.resourceTeamId, + scope.ownerMemberId, + scope.projectId, + scope.filePath, + ]); +} + +export function createInMemoryPublicFilePublicationStore(): PublicFilePublicationStore { + const publications = new Map(); + return { + get: (scope) => publications.get(scopeKey(scope)) ?? null, + set: (scope, publication) => { + publications.set(scopeKey(scope), publication); + }, + delete: (scope) => { + publications.delete(scopeKey(scope)); + }, + }; +} + +/** + * Persist public snapshot identities under the exact resource-hub principal + * that created them. There is deliberately no project foreign key: deleting a + * local project must not erase the slug needed to redact its still-public + * remote snapshot. + */ +export function createSqlitePublicFilePublicationStore( + db: SqliteDb, + now: () => number = Date.now, +): PublicFilePublicationStore { + const selectRow = db.prepare(` + SELECT url, slug, file_name AS fileName + FROM public_file_publications + WHERE resource_team_id = ? + AND owner_member_id = ? + AND project_id = ? + AND file_path = ? + `); + const upsertRow = db.prepare(` + INSERT INTO public_file_publications + (resource_team_id, owner_member_id, project_id, file_path, + url, slug, file_name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(resource_team_id, owner_member_id, project_id, file_path) + DO UPDATE SET + url = excluded.url, + slug = excluded.slug, + file_name = excluded.file_name, + updated_at = excluded.updated_at + `); + const deleteRow = db.prepare(` + DELETE FROM public_file_publications + WHERE resource_team_id = ? + AND owner_member_id = ? + AND project_id = ? + AND file_path = ? + `); + + return { + get(scope) { + const row = selectRow.get( + scope.resourceTeamId, + scope.ownerMemberId, + scope.projectId, + scope.filePath, + ) as { url?: unknown; slug?: unknown; fileName?: unknown } | undefined; + if ( + !row + || typeof row.url !== 'string' + || typeof row.slug !== 'string' + || typeof row.fileName !== 'string' + ) { + return null; + } + return { url: row.url, slug: row.slug, fileName: row.fileName }; + }, + set(scope, publication) { + const timestamp = now(); + upsertRow.run( + scope.resourceTeamId, + scope.ownerMemberId, + scope.projectId, + scope.filePath, + publication.url, + publication.slug, + publication.fileName, + timestamp, + timestamp, + ); + }, + delete(scope) { + deleteRow.run( + scope.resourceTeamId, + scope.ownerMemberId, + scope.projectId, + scope.filePath, + ); + }, + }; +} diff --git a/apps/daemon/src/db.ts b/apps/daemon/src/db.ts index caddd98381..b6c9ec9594 100644 --- a/apps/daemon/src/db.ts +++ b/apps/daemon/src/db.ts @@ -16,6 +16,7 @@ import type { import { eventsEndedWithUnfinishedWork } from '@open-design/contracts'; import { migrateCollabSyncSnapshots } from './collab/sync-snapshot-store.js'; import { migrateCommentRelayOutbox } from './collab/comment-relay-outbox.js'; +import { migratePublicFilePublications } from './collab/public-file-publication-store.js'; import { collapseWorkspaceProjectHomes, type WorkspaceProjectHomeRow, @@ -559,6 +560,7 @@ function migrate(db: SqliteDb): void { migratePlugins(db); migrateCollabSyncSnapshots(db); migrateCommentRelayOutbox(db); + migratePublicFilePublications(db); } /** diff --git a/apps/daemon/src/routes/collab-sync.ts b/apps/daemon/src/routes/collab-sync.ts index e4842fed61..db9ebca6fc 100644 --- a/apps/daemon/src/routes/collab-sync.ts +++ b/apps/daemon/src/routes/collab-sync.ts @@ -3,7 +3,10 @@ import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from 'node import os from 'node:os'; import path from 'node:path'; import { + PUBLIC_FILE_MANUAL_REVOKE_REQUIRED, workspaceContextHasWorkspaceIdentity, + type PublicFileManualRevokeRequiredResponse, + type PublicProjectFilePublication, type ProjectContentTransferState, type ProjectMetadata, type ProjectSyncIntentEvent, @@ -47,6 +50,11 @@ import { parseVelaResourceSnapshot, runVelaResourceCommand, } from '../collab/vela-cli-resource-adapter.js'; +import { + createInMemoryPublicFilePublicationStore, + type PublicFilePublicationScope, + type PublicFilePublicationStore, +} from '../collab/public-file-publication-store.js'; import { readVelaControlApiContext } from '../integrations/vela.js'; import { readProjectManifest } from '../project-locations.js'; import { redactSecrets } from '../redact.js'; @@ -208,6 +216,8 @@ export interface RegisterCollabSyncRoutesDeps { ) => Promise<{ displayName: string; role: 'owner' | 'admin' | 'member' } | null>; projectStore?: PulledProjectStore; resolveProjectDir?: (projectId: string) => string | Promise; + /** Durable publication metadata used to restore public links after restart. */ + publicFilePublicationStore?: PublicFilePublicationStore; resolvePullDir?: (projectId: string) => string; /** Read the durable local materialization cursor for this exact team mirror. */ readMaterializedVersion?: ( @@ -372,13 +382,6 @@ const PULLED_PROJECT_PLACEHOLDER_NAME = '共享项目'; const PUBLIC_FILE_RESOURCE_KIND = 'project'; const PUBLIC_FILE_REF = 'published'; -interface PublicFilePublication { - url: string; - slug: string; - fileName: string; -} - -const publicFilePublications = new Map(); const MAX_ERROR_LOG_FIELD_LENGTH = 2_048; function redactedErrorLogText(value: unknown): string { @@ -535,8 +538,17 @@ function publicFileResourceIdFor( return `project-file-${scoped}`; } -function publicFilePublicationKey(projectId: string, filePath: string, principal: ResourceHubPrincipal): string { - return JSON.stringify([principal.teamId, principal.memberId, projectId, filePath]); +function publicFilePublicationScope( + projectId: string, + filePath: string, + principal: ResourceHubPrincipal, +): PublicFilePublicationScope { + return { + resourceTeamId: principal.teamId, + ownerMemberId: principal.memberId, + projectId, + filePath, + }; } function encodePublicFileUrlPath(filePath: string): string { @@ -691,6 +703,9 @@ export function registerCollabSyncRoutes( notifyProjectMetadataChanged, } = deps; const readManifest = deps.readManifest ?? readProjectManifest; + const publicFilePublicationStore = + deps.publicFilePublicationStore + ?? createInMemoryPublicFilePublicationStore(); const ownerEnrichmentCache = new Map< string, { @@ -1277,12 +1292,45 @@ export function registerCollabSyncRoutes( if (!snapshot) { return res.status(502).json({ error: 'PUBLIC_SNAPSHOT_UNAVAILABLE' }); } - const publication = { + const publication: PublicProjectFilePublication = { url: publicSnapshotFileUrl(baseUrl, snapshot.slug, filePath), slug: snapshot.slug, fileName: filePath, }; - publicFilePublications.set(publicFilePublicationKey(projectId, filePath, principal), publication); + try { + publicFilePublicationStore.set( + publicFilePublicationScope(projectId, filePath, principal), + publication, + ); + } catch (persistenceError) { + try { + await runVelaResourceCommand([ + 'snapshot-redact', + resourceId, + snapshot.slug, + '--json', + ], principal.teamId); + } catch (redactionError) { + console.warn( + '[od] failed to persist public project file publication; snapshot compensation also failed:', + { persistenceError, redactionError }, + ); + const recoveryResponse = { + error: { + code: PUBLIC_FILE_MANUAL_REVOKE_REQUIRED, + message: + `The public link remains active at ${publication.url}. ` + + 'Run od project revoke-public-link with this project, file path, and URL.', + data: { + projectId, + ...publication, + }, + }, + } satisfies PublicFileManualRevokeRequiredResponse; + return res.status(502).json(recoveryResponse); + } + throw persistenceError; + } return res.json(publication); } catch (error) { console.warn('[od] failed to publish public project file:', error); @@ -1335,7 +1383,9 @@ export function registerCollabSyncRoutes( slug, '--json', ], principal.teamId); - publicFilePublications.delete(publicFilePublicationKey(projectId, filePath, principal)); + publicFilePublicationStore.delete( + publicFilePublicationScope(projectId, filePath, principal), + ); return res.json({ ok: true, slug, fileName: filePath }); } catch (error) { console.warn('[od] failed to unpublish public project file:', error); @@ -1376,7 +1426,9 @@ export function registerCollabSyncRoutes( return res.status(403).json({ error: 'WORKSPACE_PROJECT_PUBLISH_DENIED' }); } return res.json({ - publication: publicFilePublications.get(publicFilePublicationKey(projectId, filePath, principal)) ?? null, + publication: publicFilePublicationStore.get( + publicFilePublicationScope(projectId, filePath, principal), + ), }); }); diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 6923944091..9dd2d0d52f 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -765,6 +765,7 @@ import { import { registerTeamResourceRoutes } from './routes/team-resources.js'; import { registerTeamResourceShareRoutes } from './routes/team-resource-share.js'; import { createCollabRuntime } from './collab/runtime.js'; +import { createSqlitePublicFilePublicationStore } from './collab/public-file-publication-store.js'; import { createActiveWorkspaceSelectionStore, } from './collab/active-workspace-selection.js'; @@ -4559,6 +4560,7 @@ export async function startServer({ ): Promise => {}; const collabSyncRoutes = registerCollabSyncRoutes(app, { collab, + publicFilePublicationStore: createSqlitePublicFilePublicationStore(db), verifyWorkspaceRequest: verifiedWorkspaceContextForRequest, verifyWorkspaceReadRequest: verifiedWorkspaceReadContextForRequest, verifyWorkspaceScope: verifiedTeamMirrorScope, diff --git a/apps/daemon/tests/project-cli.test.ts b/apps/daemon/tests/project-cli.test.ts index 436bbd646e..310cba4892 100644 --- a/apps/daemon/tests/project-cli.test.ts +++ b/apps/daemon/tests/project-cli.test.ts @@ -84,6 +84,18 @@ async function startProjectStubServer(): Promise { res.end(JSON.stringify({ files: [] })); return; } + if ( + captured.method === 'DELETE' + && captured.url === '/api/projects/project-1/files/nested%2Findex.html/publish-public' + ) { + res.statusCode = 200; + res.end(JSON.stringify({ + ok: true, + slug: 'legacy-public-slug', + fileName: 'nested/index.html', + })); + return; + } if (captured.method === 'GET' && captured.url === '/api/workspaces/ws-1/projects?view=team') { res.statusCode = 200; res.end(JSON.stringify({ @@ -249,6 +261,44 @@ describe('od project CLI', () => { }); }); + it('revokes a legacy public link by extracting its snapshot slug', async () => { + stub = await startProjectStubServer(); + + const result = await runCli([ + 'project', + 'revoke-public-link', + 'project-1', + '--path', + 'nested/index.html', + '--url', + 'https://hub.example.test/api/v1/public/snapshots/legacy-public-slug/files/nested/index.html', + '--workspace', + 'ws-1', + '--workspace-member', + 'member-1', + '--daemon-url', + stub.baseUrl, + '--json', + ]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + slug: 'legacy-public-slug', + }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ + method: 'DELETE', + url: '/api/projects/project-1/files/nested%2Findex.html/publish-public', + headers: { + 'x-od-workspace-id': 'ws-1', + 'x-od-workspace-member-id': 'member-1', + }, + body: JSON.stringify({ slug: 'legacy-public-slug' }), + }); + }); + it('creates a design-system project with prompt-file content and JSON output', async () => { stub = await startProjectStubServer(); tempRoot = mkdtempSync(join(tmpdir(), 'od-project-cli-')); diff --git a/apps/daemon/tests/public-file-publication-restart.test.ts b/apps/daemon/tests/public-file-publication-restart.test.ts new file mode 100644 index 0000000000..40b11dfb5b --- /dev/null +++ b/apps/daemon/tests/public-file-publication-restart.test.ts @@ -0,0 +1,289 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type WorkspaceCollabContext, +} from '@open-design/contracts'; +import { createCollabRuntime, type CollabRuntime } from '../src/collab/runtime.js'; +import { + createSqlitePublicFilePublicationStore, + type PublicFilePublicationStore, +} from '../src/collab/public-file-publication-store.js'; +import { closeDatabase, openDatabase } from '../src/db.js'; + +const vela = vi.hoisted(() => ({ + runResourceCommand: vi.fn(), +})); +const originalResourceHubUrl = process.env.OD_RESOURCE_HUB_URL; + +vi.mock('../src/collab/vela-cli-resource-adapter.js', async (importOriginal) => { + const actual = await importOriginal< + typeof import('../src/collab/vela-cli-resource-adapter.js') + >(); + return { + ...actual, + runVelaResourceCommand: vela.runResourceCommand, + }; +}); + +vi.mock('../src/integrations/vela.js', () => ({ + readVelaControlApiContext: () => null, +})); + +const context: WorkspaceCollabContext = { + workspaceId: 'workspace-1', + workspaceType: 'team', + teamId: 'team-1', + workspaceMemberId: 'member-1', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ + role: 'owner', + lifecycleState: 'active', + }), +}; + +let server: http.Server | null = null; +let runtime: CollabRuntime | null = null; +const tempDirs: string[] = []; + +afterEach(async () => { + if (originalResourceHubUrl === undefined) delete process.env.OD_RESOURCE_HUB_URL; + else process.env.OD_RESOURCE_HUB_URL = originalResourceHubUrl; + vela.runResourceCommand.mockReset(); + closeDatabase(); + runtime?.dispose(); + runtime = null; + if (server) { + const current = server; + server = null; + await new Promise((resolve) => current.close(() => resolve())); + } + while (tempDirs.length > 0) { + await rm(tempDirs.pop()!, { recursive: true, force: true }); + } + vi.resetModules(); +}); + +async function startDaemon( + projectDir: string, + publicationStore: PublicFilePublicationStore, +) { + const { registerCollabSyncRoutes } = await import('../src/routes/collab-sync.js'); + const app = express(); + app.use(express.json()); + runtime = createCollabRuntime({ + workspaceContext: { current: async () => context }, + }); + registerCollabSyncRoutes(app, { + collab: runtime, + verifyWorkspaceRequest: async (req) => + req.get('x-od-workspace-id') === context.workspaceId + && req.get('x-od-workspace-member-id') === context.workspaceMemberId + ? context + : null, + resolveSharedProject: async () => null, + resolveProjectDir: () => projectDir, + publicFilePublicationStore: publicationStore, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind'); + const baseUrl = `http://127.0.0.1:${address.port}`; + return { + async request(method: string) { + const response = await fetch( + `${baseUrl}/api/projects/project-1/files/index.html/publish-public`, + { + method, + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': context.workspaceId, + 'x-od-workspace-member-id': context.workspaceMemberId, + }, + ...(method === 'DELETE' + ? { body: JSON.stringify({ slug: 'restart-safe-slug' }) } + : {}), + }, + ); + return { + status: response.status, + body: await response.json() as Record, + }; + }, + async stop() { + runtime?.dispose(); + runtime = null; + const current = server; + server = null; + if (current) { + await new Promise((resolve) => current.close(() => resolve())); + } + }, + }; +} + +describe('public file publication restart lifecycle', () => { + it('redacts a new snapshot when publication persistence fails', async () => { + const projectDir = await mkdtemp(path.join(tmpdir(), 'od-public-persist-fail-')); + tempDirs.push(projectDir); + await writeFile(path.join(projectDir, 'index.html'), '

Public

'); + process.env.OD_RESOURCE_HUB_URL = 'https://hub.example.test'; + vela.runResourceCommand.mockImplementation(async (args: string[]) => + args[0] === 'snapshot' + ? JSON.stringify({ + slug: 'unpersisted-slug', + name: 'index.html', + kind: 'project', + versionId: 'version-1', + createdAt: new Date(1).toISOString(), + }) + : JSON.stringify({ version: 1 }), + ); + const publicationStore: PublicFilePublicationStore = { + get: () => null, + set: () => { + throw new Error('sqlite disk full'); + }, + delete: () => {}, + }; + const daemon = await startDaemon(projectDir, publicationStore); + + const publish = await daemon.request('POST'); + + expect(publish).toEqual({ + status: 502, + body: { error: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE' }, + }); + expect(vela.runResourceCommand.mock.calls.map(([args]) => args[0])).toEqual([ + 'push', + 'snapshot', + 'snapshot-redact', + ]); + expect(vela.runResourceCommand).toHaveBeenLastCalledWith( + [ + 'snapshot-redact', + expect.stringMatching(/^project-file-/u), + 'unpersisted-slug', + '--json', + ], + 'team-1', + ); + }); + + it('returns the public URL and recovery command when compensation fails', async () => { + const projectDir = await mkdtemp(path.join(tmpdir(), 'od-public-recovery-')); + tempDirs.push(projectDir); + await writeFile(path.join(projectDir, 'index.html'), '

Public

'); + process.env.OD_RESOURCE_HUB_URL = 'https://hub.example.test'; + vela.runResourceCommand.mockImplementation(async (args: string[]) => { + if (args[0] === 'snapshot') { + return JSON.stringify({ + slug: 'manual-revoke-slug', + name: 'index.html', + kind: 'project', + versionId: 'version-1', + createdAt: new Date(1).toISOString(), + }); + } + if (args[0] === 'snapshot-redact') { + throw new Error('resource hub unavailable'); + } + return JSON.stringify({ version: 1 }); + }); + const publicationStore: PublicFilePublicationStore = { + get: () => null, + set: () => { + throw new Error('sqlite disk full'); + }, + delete: () => {}, + }; + const daemon = await startDaemon(projectDir, publicationStore); + + const publish = await daemon.request('POST'); + + expect(publish.status).toBe(502); + expect(publish.body).toMatchObject({ + error: { + code: 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED', + data: { + url: 'https://hub.example.test/api/v1/public/snapshots/manual-revoke-slug/files/index.html', + slug: 'manual-revoke-slug', + fileName: 'index.html', + }, + }, + }); + expect((publish.body.error as { message: string }).message).toContain( + 'od project revoke-public-link', + ); + expect((publish.body.error as { message: string }).message).toContain( + 'https://hub.example.test/api/v1/public/snapshots/manual-revoke-slug/files/index.html', + ); + }); + + it('restores and revokes a published link after the daemon restarts', async () => { + const projectDir = await mkdtemp(path.join(tmpdir(), 'od-public-restart-')); + tempDirs.push(projectDir); + await writeFile(path.join(projectDir, 'index.html'), '

Public

'); + process.env.OD_RESOURCE_HUB_URL = 'https://hub.example.test'; + vela.runResourceCommand.mockImplementation(async (args: string[]) => + args[0] === 'snapshot' + ? JSON.stringify({ + slug: 'restart-safe-slug', + name: 'index.html', + kind: 'project', + versionId: 'version-1', + createdAt: new Date(1).toISOString(), + }) + : JSON.stringify({ version: 1 }), + ); + let publicationStore = createSqlitePublicFilePublicationStore( + openDatabase(projectDir, { dataDir: projectDir }), + ); + const firstDaemon = await startDaemon(projectDir, publicationStore); + + const publish = await firstDaemon.request('POST'); + expect(publish.status).toBe(200); + await firstDaemon.stop(); + closeDatabase(); + + // A fresh module instance and reopened SQLite file represent a daemon + // process restart, not merely a second route registration. + vi.resetModules(); + publicationStore = createSqlitePublicFilePublicationStore( + openDatabase(projectDir, { dataDir: projectDir }), + ); + const restartedDaemon = await startDaemon(projectDir, publicationStore); + const restored = await restartedDaemon.request('GET'); + const revoked = await restartedDaemon.request('DELETE'); + const afterRevoke = await restartedDaemon.request('GET'); + + expect(restored.body.publication).toEqual({ + url: 'https://hub.example.test/api/v1/public/snapshots/restart-safe-slug/files/index.html', + slug: 'restart-safe-slug', + fileName: 'index.html', + }); + expect(revoked.status).toBe(200); + expect(afterRevoke.body.publication).toBeNull(); + expect(vela.runResourceCommand).toHaveBeenCalledWith( + [ + 'snapshot-redact', + expect.stringMatching(/^project-file-/u), + 'restart-safe-slug', + '--json', + ], + 'team-1', + ); + }); +}); diff --git a/apps/daemon/tests/public-file-publication-store.test.ts b/apps/daemon/tests/public-file-publication-store.test.ts new file mode 100644 index 0000000000..e8006ed4e1 --- /dev/null +++ b/apps/daemon/tests/public-file-publication-store.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { closeDatabase, openDatabase } from '../src/db.js'; +import { + createSqlitePublicFilePublicationStore, + type PublicFilePublicationScope, +} from '../src/collab/public-file-publication-store.js'; + +let tempDir: string | null = null; + +afterEach(() => { + closeDatabase(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +describe('SQLite public file publication store', () => { + it('restores a publication after the database is closed and reopened', () => { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-public-publication-')); + const scope: PublicFilePublicationScope = { + resourceTeamId: 'team-1', + ownerMemberId: 'member-1', + projectId: 'project-1', + filePath: 'nested/index.html', + }; + const publication = { + url: 'https://hub.example.test/public/snapshot-1/nested/index.html', + slug: 'snapshot-1', + fileName: 'nested/index.html', + }; + const first = createSqlitePublicFilePublicationStore( + openDatabase(tempDir, { dataDir: tempDir }), + ); + first.set(scope, publication); + + closeDatabase(); + const reopened = createSqlitePublicFilePublicationStore( + openDatabase(tempDir, { dataDir: tempDir }), + ); + + expect(reopened.get(scope)).toEqual(publication); + expect(reopened.get({ ...scope, ownerMemberId: 'member-2' })).toBeNull(); + reopened.delete(scope); + expect(reopened.get(scope)).toBeNull(); + }); +}); diff --git a/apps/web/src/collab/public-file-publish.ts b/apps/web/src/collab/public-file-publish.ts index 4dea2430c2..54f9104fc3 100644 --- a/apps/web/src/collab/public-file-publish.ts +++ b/apps/web/src/collab/public-file-publish.ts @@ -1,8 +1,40 @@ import { + PUBLIC_FILE_MANUAL_REVOKE_REQUIRED, workspaceContextHasWorkspaceIdentity, + type PublicFileManualRevokeRequiredData, + type PublicProjectFilePublication, type WorkspaceCollabContext, } from '@open-design/contracts'; +export class PublicFilePublishError extends Error { + constructor( + message: string, + readonly status: number, + readonly code?: string, + readonly data?: PublicFileManualRevokeRequiredData, + ) { + super(message); + this.name = 'PublicFilePublishError'; + } +} + +export function publicFileManualRevokePublication( + error: unknown, +): PublicProjectFilePublication | null { + if ( + !(error instanceof PublicFilePublishError) + || error.code !== PUBLIC_FILE_MANUAL_REVOKE_REQUIRED + || !error.data + ) { + return null; + } + return { + url: error.data.url, + slug: error.data.slug, + fileName: error.data.fileName, + }; +} + /** * Whether the public single-file "Publish" entry point may be rendered. * diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index 0393ce2798..1e96bc6a67 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -88,6 +88,7 @@ import { } from '../collab/useWorkspaceContext'; import { canPublishPublicFile, + publicFileManualRevokePublication, publicFilePublishFailureKey, type PublicFilePublishFailureKey, } from '../collab/public-file-publish'; @@ -6621,6 +6622,7 @@ function ReactComponentViewer({ setPublishedFileSlug(response.slug); } catch (error) { console.warn('[FileViewer] failed to publish public file', error); + const recoveryPublication = publicFileManualRevokePublication(error); firePublishResult({ action: 'publish', result: 'failed', @@ -6628,8 +6630,15 @@ function ReactComponentViewer({ publish_duration_ms: Math.round(performance.now() - publishStarted), }); if (publicFileRequestSeqRef.current === requestSeq) { - setPublishLinkFeedback('failed'); - setPublishFailureKey(publicFilePublishFailureKey(error)); + if (recoveryPublication) { + setPublishedFileUrl(recoveryPublication.url); + setPublishedFileSlug(recoveryPublication.slug); + setPublishLinkFeedback(null); + setPublishFailureKey(null); + } else { + setPublishLinkFeedback('failed'); + setPublishFailureKey(publicFilePublishFailureKey(error)); + } } } finally { if (publicFileRequestSeqRef.current === requestSeq) setPublishingPublicFile(false); @@ -8050,6 +8059,7 @@ function HtmlViewer({ setPublishedFileSlug(response.slug); } catch (error) { console.warn('[FileViewer] failed to publish public file', error); + const recoveryPublication = publicFileManualRevokePublication(error); firePublishResult({ action: 'publish', result: 'failed', @@ -8057,8 +8067,15 @@ function HtmlViewer({ publish_duration_ms: Math.round(performance.now() - publishStarted), }); if (publicFileRequestSeqRef.current === requestSeq) { - setPublishLinkFeedback('failed'); - setPublishFailureKey(publicFilePublishFailureKey(error)); + if (recoveryPublication) { + setPublishedFileUrl(recoveryPublication.url); + setPublishedFileSlug(recoveryPublication.slug); + setPublishLinkFeedback(null); + setPublishFailureKey(null); + } else { + setPublishLinkFeedback('failed'); + setPublishFailureKey(publicFilePublishFailureKey(error)); + } } } finally { if (publicFileRequestSeqRef.current === requestSeq) setPublishingPublicFile(false); diff --git a/apps/web/src/providers/registry.ts b/apps/web/src/providers/registry.ts index d25f309995..2e0d1a9117 100644 --- a/apps/web/src/providers/registry.ts +++ b/apps/web/src/providers/registry.ts @@ -1,4 +1,9 @@ -import { workspaceContextHasTeamIdentity } from '@open-design/contracts'; +import { + PUBLIC_FILE_MANUAL_REVOKE_REQUIRED, + workspaceContextHasTeamIdentity, + type PublicFileManualRevokeRequiredData, + type PublicProjectFilePublication, +} from '@open-design/contracts'; import { boundedRequestErrorCode } from '../analytics/workspace'; import type { ConnectorAuthConfigPrepareResponse, @@ -97,6 +102,7 @@ import { workspaceIdentityCacheKey, workspaceResourceUrl, } from '../collab/workspace-identity'; +import { PublicFilePublishError } from '../collab/public-file-publish'; export const DEFAULT_DEPLOY_PROVIDER_ID = 'vercel-self'; export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages'; @@ -114,11 +120,7 @@ export type WebDeployProjectFileResponse = DeployProjectFileResponse; export type WebCloudflarePagesDeploySelection = CloudflarePagesDeploySelection; export type WebCloudflarePagesZonesResponse = CloudflarePagesZonesResponse; -export interface WebPublicProjectFileResponse { - url: string; - slug: string; - fileName: string; -} +export type WebPublicProjectFileResponse = PublicProjectFilePublication; export function isDeployProviderId(value: unknown): value is WebDeployProviderId { return typeof value === 'string' && (DEPLOY_PROVIDER_IDS as readonly string[]).includes(value); @@ -1709,6 +1711,31 @@ export async function deployProjectFile( return (await resp.json()) as WebDeployProjectFileResponse; } +function parsePublicFileManualRevokeData( + value: unknown, +): PublicFileManualRevokeRequiredData | undefined { + if (!value || typeof value !== 'object') return undefined; + const data = value as Partial>; + if ( + typeof data.projectId !== 'string' + || typeof data.url !== 'string' + || typeof data.slug !== 'string' + || typeof data.fileName !== 'string' + || !data.projectId + || !data.url + || !data.slug + || !data.fileName + ) { + return undefined; + } + return { + projectId: data.projectId, + url: data.url, + slug: data.slug, + fileName: data.fileName, + }; +} + export async function publishProjectFilePublic( projectId: string, fileName: string, @@ -1727,15 +1754,38 @@ export async function publishProjectFilePublic( ); if (!resp.ok) { const payload = (await resp.json().catch(() => null)) as - | { error?: { message?: string } | string; message?: string } + | { + error?: { code?: unknown; message?: unknown; data?: unknown } | string; + message?: unknown; + } | null; + const structuredError = payload?.error && typeof payload.error === 'object' + ? payload.error + : null; + const code = typeof structuredError?.code === 'string' + ? structuredError.code + : typeof payload?.error === 'string' + ? payload.error + : undefined; const errorMessage = - typeof payload?.error === 'object' - ? payload.error.message - : typeof payload?.error === 'string' + typeof structuredError?.message === 'string' + ? structuredError.message + : typeof payload?.error === 'string' ? payload.error - : payload?.message; - throw new Error(errorMessage || `Publish failed (${resp.status})`); + : typeof payload?.message === 'string' + ? payload.message + : undefined; + const recoveryData = code === PUBLIC_FILE_MANUAL_REVOKE_REQUIRED + ? parsePublicFileManualRevokeData(structuredError?.data) + : undefined; + throw new PublicFilePublishError( + errorMessage || `Publish failed (${resp.status})`, + resp.status, + code, + recoveryData?.projectId === projectId && recoveryData.fileName === fileName + ? recoveryData + : undefined, + ); } return (await resp.json()) as WebPublicProjectFileResponse; } diff --git a/apps/web/tests/components/FileViewer.test.tsx b/apps/web/tests/components/FileViewer.test.tsx index 8d899518e2..f5dd778ab3 100644 --- a/apps/web/tests/components/FileViewer.test.tsx +++ b/apps/web/tests/components/FileViewer.test.tsx @@ -5520,6 +5520,68 @@ describe('FileViewer SVG artifacts', () => { ); }); + it('exposes Stop sharing when publication persistence and compensation both fail', async () => { + const context = teamWorkspaceContext(); + const publicUrl = + 'https://hub.example.test/api/v1/public/snapshots/manual-revoke-slug/files/index.html'; + const unpublishBodies: unknown[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/api/workspace/context')) { + return new Response(JSON.stringify({ context }), { status: 200 }); + } + if (url.includes('publish-public')) { + const method = (init?.method ?? 'GET').toUpperCase(); + if (method === 'GET') { + return new Response(JSON.stringify({ publication: null }), { status: 200 }); + } + if (method === 'DELETE') { + unpublishBodies.push(JSON.parse(String(init?.body))); + return new Response( + JSON.stringify({ ok: true, slug: 'manual-revoke-slug', fileName: 'index.html' }), + { status: 200 }, + ); + } + return new Response( + JSON.stringify({ + error: { + code: 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED', + message: `The public link remains active at ${publicUrl}.`, + data: { + projectId: 'project-1', + url: publicUrl, + slug: 'manual-revoke-slug', + fileName: 'index.html', + }, + }, + }), + { status: 502 }, + ); + } + return new Response(JSON.stringify({ deployments: [] }), { status: 200 }); + }), + ); + + renderWithProjectWorkspace( + , + context, + ); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + fireEvent.click(await screen.findByRole('menuitem', { name: /Get a share link/i })); + + expect(await screen.findByText(publicUrl)).toBeTruthy(); + const stopSharing = screen.getByRole('button', { name: /Stop sharing/i }); + fireEvent.click(stopSharing); + + await waitFor(() => expect(unpublishBodies).toEqual([{ slug: 'manual-revoke-slug' }])); + expect(await screen.findByRole('menuitem', { name: /Get a share link/i })).toBeTruthy(); + }); + // Reading the help must never publish. The publish row's trailing "?" carries // the reach + single-file limitation copy, i.e. exactly what a user wants to // read BEFORE committing — but it used to be nested inside the same diff --git a/packages/contracts/src/api/collab.ts b/packages/contracts/src/api/collab.ts index 124b08cbba..6cdbccb14d 100644 --- a/packages/contracts/src/api/collab.ts +++ b/packages/contracts/src/api/collab.ts @@ -17,6 +17,29 @@ import type { export type CollabMemberRole = 'owner' | 'admin' | 'member'; +/** Public single-file snapshot returned by the daemon publish routes. */ +export interface PublicProjectFilePublication { + url: string; + slug: string; + fileName: string; +} + +export const PUBLIC_FILE_MANUAL_REVOKE_REQUIRED = + 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED' as const; + +/** Recovery data returned when a new public snapshot could not be persisted or redacted. */ +export interface PublicFileManualRevokeRequiredData extends PublicProjectFilePublication { + projectId: string; +} + +export interface PublicFileManualRevokeRequiredResponse { + error: { + code: typeof PUBLIC_FILE_MANUAL_REVOKE_REQUIRED; + message: string; + data: PublicFileManualRevokeRequiredData; + }; +} + /** A member present in a shared project (heartbeat identity). */ export interface CollabPresenceMember { memberId: string;