diff --git a/.changeset/piece-viewer.md b/.changeset/piece-viewer.md new file mode 100644 index 00000000..d2bd42cb --- /dev/null +++ b/.changeset/piece-viewer.md @@ -0,0 +1,10 @@ +--- +"@luzzle/web": patch +"@luzzle/web.worker": patch +--- + +Add a Published piece viewer and align Preview with shared Overview and composition-only Share views, including inline output inspection. Replace legacy Live/OG navigation with View → Published / Preview and Share. + +Record successful per-piece publication times, sync saved Markdown to its index, and compare indexed and published hashes. + +Restrict public and Preview asset file access to validated directories, including resolved symlink targets. diff --git a/apps/core/src/database/client.scope.test.ts b/apps/core/src/database/client.scope.test.ts new file mode 100644 index 00000000..13df920b --- /dev/null +++ b/apps/core/src/database/client.scope.test.ts @@ -0,0 +1,40 @@ +import { expect, expectTypeOf, test } from 'vitest' +import { asCoreDatabase, getDatabaseClient } from './client.js' +import migrate from './migrations.js' +import type { LuzzleDatabase } from './tables/index.js' + +test('asCoreDatabase accepts an existing core client without replacing it', async () => { + const db = getDatabaseClient(':memory:') + try { + const core = asCoreDatabase(db) + expect(core).toBe(db) + expectTypeOf(core).toEqualTypeOf() + // @ts-expect-error The input schema must contain all core tables. + expectTypeOf(asCoreDatabase<{ application_items: { id: string } }>).toBeFunction() + } finally { + await db.destroy() + } +}) + +test('asCoreDatabase shares the expanded client and its data, retaining app table access', async () => { + const db = getDatabaseClient(':memory:').withTables<{ application_items: { id: string } }>() + try { + const core = asCoreDatabase(db) + expect(core).toBe(db) + expectTypeOf(core).toEqualTypeOf() + expect((await migrate(core)).error).toBeUndefined() + await core + .insertInto('pieces_cache') + .values({ id: 'cache-id', file_path: 'books/test.books.md', content_hash: 'saved-hash' }) + .execute() + expect( + (await db.selectFrom('pieces_cache').selectAll().executeTakeFirstOrThrow()).content_hash + ).toBe('saved-hash') + + await db.schema.createTable('application_items').addColumn('id', 'text').execute() + await db.insertInto('application_items').values({ id: 'app-id' }).execute() + expect(await db.selectFrom('application_items').selectAll().execute()).toEqual([{ id: 'app-id' }]) + } finally { + await db.destroy() + } +}) diff --git a/apps/core/src/database/client.ts b/apps/core/src/database/client.ts index bcfb2430..146149c7 100644 --- a/apps/core/src/database/client.ts +++ b/apps/core/src/database/client.ts @@ -1,7 +1,7 @@ import { Kysely } from 'kysely' import { NodeSqliteDialect } from './NodeSqliteDialect.js' import { DatabaseSync } from 'node:sqlite' -import type { LuzzleTables } from './tables/index.js' +import type { LuzzleDatabase, LuzzleTables } from './tables/index.js' function getDatabaseClient(pathToDb: string, debug = false) { const db = new DatabaseSync(pathToDb) @@ -17,4 +17,8 @@ function getDatabaseClient(pathToDb: string, debug = false) { }) } -export { getDatabaseClient } +function asCoreDatabase(db: Kysely): LuzzleDatabase { + return db as unknown as LuzzleDatabase +} + +export { getDatabaseClient, asCoreDatabase } diff --git a/apps/core/src/index.test.ts b/apps/core/src/index.test.ts index 68e15d11..31b91e8f 100644 --- a/apps/core/src/index.test.ts +++ b/apps/core/src/index.test.ts @@ -6,6 +6,7 @@ import * as index from './index.js' describe('index.ts', () => { test('schema', () => { expect(index).toBeDefined() + expect(index.asCoreDatabase).toBeTypeOf('function') }) test('preserves the public generation exports without exposing provider internals', () => { diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 35a50dc5..0f8a3e06 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -3,7 +3,7 @@ import compile from './lib/ajv.js' import { sql } from 'kysely' import { extractFullMarkdown } from './lib/markdown.js' import { addFrontMatter } from './lib/frontmatter.js' -import { getDatabaseClient } from './database/client.js' +import { getDatabaseClient, asCoreDatabase } from './database/client.js' import { filterFrontmatterFields, findFrontmatterField, @@ -23,6 +23,7 @@ export { mergeGeneratedFields, appendGeneratedBody } from './pieces/utils/genera export { getDatabaseClient, + asCoreDatabase, migrate, sql, compile, diff --git a/apps/core/src/pieces/item.test.ts b/apps/core/src/pieces/item.test.ts index 66dede0b..b72230d2 100644 --- a/apps/core/src/pieces/item.test.ts +++ b/apps/core/src/pieces/item.test.ts @@ -156,9 +156,23 @@ describe('src/pieces/item.ts', () => { file_path: path, note_markdown: note, frontmatter_json: JSON.stringify(frontmatter), + assets_json_array: '[]', }) }) + test('makePieceItemUpdatable clears the last asset reference', () => { + const data = makePieceItemSelectable({ + frontmatter_json: JSON.stringify({ title: 'title', cover: 'cover.jpg' }), + assets_json_array: JSON.stringify(['cover.jpg']), + }) + const markdown = makeMarkdownSample(data.file_path, 'books', 'note', { title: 'title' }) + const schema = makeSchema({ cover: { type: 'string', format: 'asset' } }) + + const update = database.makePieceItemUpdatable(markdown, schema, data) + + expect(update.assets_json_array).toBe('[]') + }) + test('makePieceItemUpdatable with recursive assets', () => { const data = makePieceItemSelectable() const note = 'note' diff --git a/apps/core/src/pieces/item.ts b/apps/core/src/pieces/item.ts index 5bf7440a..63421d6e 100644 --- a/apps/core/src/pieces/item.ts +++ b/apps/core/src/pieces/item.ts @@ -116,7 +116,7 @@ function makePieceItemUpdatable( } if (force || data.assets_json_array !== assetString) { - update.assets_json_array = assetString !== '[]' ? assetString : undefined + update.assets_json_array = assetString } return update diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index c5a6eff0..28171e84 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -14,6 +14,8 @@ declare global { interface PageData { session: Session | null config: AppConfigPublic + preview?: boolean + job?: string meta: { title?: string description?: string diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index 2ca28c99..ce6f59bb 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -24,15 +24,28 @@ beforeEach(() => { }) afterEach(() => vi.resetModules()) -function event(session: object | null, pathname = '/api/admin/generate') { +function event( + session: object | null, + pathname = '/api/admin/generate', + { + routeId = pathname, + isDataRequest = false, + method = 'GET' + }: { routeId?: string | null; isDataRequest?: boolean; method?: string } = {} +) { return { url: new URL(`http://localhost${pathname}`), + request: new Request(`http://localhost${pathname}${isDataRequest ? '/__data.json' : ''}`, { + method + }), + route: { id: routeId }, + isDataRequest, locals: { auth: vi.fn().mockResolvedValue(session) } } as unknown as Parameters[0]['event'] } -test.each(['/admin', '/admin/pieces', '/api/admin/generate', '/admin/lsp', '/administrator'])( - 'the protected prefix %s requires a session before resolving its handler', +test.each(['/admin', '/admin/pieces', '/api/admin', '/api/admin/generate', '/admin/lsp'])( + 'the protected route %s requires a session before resolving its handler', async (pathname) => { const { handle } = await import('./hooks.server') const resolve = vi.fn() @@ -81,6 +94,86 @@ test('an authenticated generation request reaches its handler', async () => { ) }) +const encodedRequests = [ + ...['', '/page', '/icon', '/share'].flatMap((suffix) => + [false, true].map((isDataRequest) => ({ + pathname: '/%61dmin/piece/books/book.books.md/published' + suffix, + routeId: '/admin/piece/[...path]/published' + suffix, + isDataRequest, + method: 'GET' + })) + ), + { + pathname: '/%61dmin/piece/books/book.books.md/source', + routeId: '/admin/piece/[...path]/source', + method: 'POST' + }, + { pathname: '/%61pi/admin/generate', routeId: '/api/admin/generate', method: 'POST' }, + { pathname: '/api/%61dmin/publish', routeId: '/api/admin/publish', method: 'POST' }, + { pathname: '/garden/admin/pieces', routeId: '/admin/pieces', method: 'GET' }, + { pathname: '/public-alias', routeId: '/admin/pieces', method: 'GET' } +] + +test.each(encodedRequests)( + '$method $pathname (data=$isDataRequest) requires a session by matched route identity', + async ({ pathname, ...options }) => { + const { handle } = await import('./hooks.server') + const resolve = vi.fn() + const request = event(null, pathname, options) + await expect(handle({ event: request, resolve })).rejects.toMatchObject({ + status: 302, + location: `/signin?redirectTo=${pathname}` + }) + expect(request.locals.auth).toHaveBeenCalledOnce() + expect(resolve).not.toHaveBeenCalled() + } +) + +test.each(encodedRequests)( + '$pathname remains inaccessible with auth disabled, even with a session (data=$isDataRequest)', + async ({ pathname, ...options }) => { + fixture.config.auth = undefined + const { handle } = await import('./hooks.server') + const resolve = vi.fn() + const request = event({ user: { name: 'test' } }, pathname, options) + await expect(handle({ event: request, resolve })).rejects.toMatchObject({ + status: 302, + location: '/' + }) + expect(request.locals.auth).not.toHaveBeenCalled() + expect(resolve).not.toHaveBeenCalled() + } +) + +test.each(encodedRequests)( + 'authenticated $pathname resolves normally (data=$isDataRequest)', + async ({ pathname, ...options }) => { + const { handle } = await import('./hooks.server') + const response = new Response('resolved') + const resolve = vi.fn().mockResolvedValue(response) + const request = event({ user: { name: 'test' } }, pathname, options) + await expect(handle({ event: request, resolve })).resolves.toBe(response) + expect(request.locals.auth).toHaveBeenCalledOnce() + expect(resolve).toHaveBeenCalledExactlyOnceWith(request) + } +) + +test.each([ + { pathname: '/administrator', routeId: '/administrator' }, + { pathname: '/api/administer', routeId: '/api/administer' }, + { pathname: '/admin-looking-alias', routeId: '/public' }, + { pathname: '/', routeId: '/(root)' }, + { pathname: '/admin/not-a-route', routeId: null } +])('public/unmatched $routeId is not classified by URL spelling', async ({ pathname, routeId }) => { + const { handle } = await import('./hooks.server') + const response = new Response('public or not found') + const resolve = vi.fn().mockResolvedValue(response) + const request = event(null, pathname, { routeId }) + await expect(handle({ event: request, resolve })).resolves.toBe(response) + expect(request.locals.auth).not.toHaveBeenCalled() + expect(resolve).toHaveBeenCalledExactlyOnceWith(request) +}) + test.each([ { auth: credentialsAuth, provider: 'credentials' }, { auth: oidcAuth, provider: 'oidc' } diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index f804d769..b82b01e5 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -57,10 +57,15 @@ const authHandle = auth const PROTECTED_PREFIXES = ['/admin', '/api/admin'] -const guardHandle: Handle = async ({ event, resolve }) => { - const isProtected = PROTECTED_PREFIXES.some((prefix) => event.url.pathname.startsWith(prefix)) +function isProtectedRoute(routeId: string | null) { + return ( + routeId !== null && + PROTECTED_PREFIXES.some((prefix) => routeId === prefix || routeId.startsWith(`${prefix}/`)) + ) +} - if (isProtected) { +const guardHandle: Handle = async ({ event, resolve }) => { + if (isProtectedRoute(event.route.id)) { if (!auth) { throw redirect(302, '/') } diff --git a/apps/web/src/lib/components/editor/PieceActions.svelte b/apps/web/src/lib/components/editor/PieceActions.svelte index 55681bd6..0a832030 100644 --- a/apps/web/src/lib/components/editor/PieceActions.svelte +++ b/apps/web/src/lib/components/editor/PieceActions.svelte @@ -2,36 +2,24 @@ import { DropdownMenu } from 'bits-ui' import DotsThreeVertical from 'virtual:icons/ph/dots-three-vertical' import { goto } from '$app/navigation' + import { editorHref } from '$lib/pieces/navigation' type Props = { file: string currentMode: 'source' | 'preview' - isDirty: boolean canGenerate: boolean - hasPublicVersion: boolean - isPublicStale: boolean + canPublish: boolean onDelete: () => void onAttach?: () => void onGenerate?: () => void } - let { - file, - currentMode, - canGenerate, - hasPublicVersion, - isPublicStale, - onDelete, - onAttach, - onGenerate - }: Props = $props() - - const previewUrl = `/admin/piece/${file}/preview` - const liveUrl = `/admin/piece/${file}/live` + let { file, currentMode, canGenerate, canPublish, onDelete, onAttach, onGenerate }: Props = + $props() - + @@ -40,15 +28,7 @@ {#if open}