diff --git a/backend/api/pages.ts b/backend/api/pages.ts index d03a7c211..95fd9d863 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -55,6 +55,45 @@ function actorFrom(req: FastifyRequest): PageActor | null { } } +/** + * Permissions that make a page's password irrelevant to the holder. + * + * Whoever may edit a page can read its source in the editor and can take the password off it + * altogether, so asking them for it protects nothing. Everybody else — including a logged in reader — + * has to enter it. + * + * Site-wide rather than per page, because per-path rules are not implemented. See the FIXME on the + * page-permissions route below. + */ +const PASSWORD_BYPASS = ['write:pages', 'manage:pages', 'manage:system'] + +/** + * Every page permission a group can be granted, i.e. the whole set `manage:system` amounts to. Mirrors + * the page rules offered in the group editor. + */ +const PAGE_PERMISSIONS = [ + 'read:pages', + 'write:pages', + 'review:pages', + 'manage:pages', + 'delete:pages' +] + +function mayBypassPassword(req: FastifyRequest): boolean { + const permissions = req.apiKey?.permissions ?? req.session?.permissions ?? [] + return PASSWORD_BYPASS.some((permission) => permissions.includes(permission)) +} + +/** + * Whether the password on a page has already been satisfied for this request. + * + * The unlock is recorded on the session — server side, by page id — so that reading a page the reader + * unlocked a moment ago does not ask again, and so that nothing the browser can set decides this. + */ +function unlockedFor(req: FastifyRequest, pageId: string): boolean { + return mayBypassPassword(req) || Boolean(req.session?.unlockedPages?.includes(pageId)) +} + /** * Pages API Routes */ @@ -111,7 +150,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Search pages', description: - 'Postgres full-text search over the pages of a site, ranked by relevance. `query` may be left out, in which case the filters alone decide the results — which is what a search for nothing but tags is.\n\nReadable without a session, for the same reason reading a page is: an anonymous request only matches published pages with no password on them. Drafts are included only for someone who may write pages. A page marked as not searchable never appears, whoever is asking.\n\n`highlight` is an excerpt with the matched terms wrapped in ``, and is the only field carrying markup — the excerpt is escaped before those are added. It is absent unless term highlighting is enabled in the search settings.', + 'Postgres full-text search over the pages of a site, ranked by relevance. `query` may be left out, in which case the filters alone decide the results — which is what a search for nothing but tags is.\n\nReadable without a session, for the same reason reading a page is: an anonymous request only matches published pages. Drafts are included only for someone who may write pages, and password-protected pages only for someone who may edit them, since a result carries an excerpt of the page text. A page marked as not searchable never appears, whoever is asking.\n\n`highlight` is an excerpt with the matched terms wrapped in ``, and is the only field carrying markup — the excerpt is escaped before those are added. It is absent unless term highlighting is enabled in the search settings.', tags: ['Pages'], params: siteIdParam, querystring: { @@ -222,7 +261,10 @@ async function routes(app: FastifyInstance) { // -> An unpublished page is only of interest to someone who could have written it includeDrafts: ['write:pages', 'manage:pages', 'manage:system'].some((p) => permissions.includes(p) - ) + ), + // -> Same rule as the page view: a protected page's text is for whoever holds the password, + // and a search excerpt is that text + hideProtected: !mayBypassPassword(req) }) } ) @@ -239,7 +281,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Get a single page', description: - "Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in — but an anonymous request only ever sees published pages with no password on them, and never their source. Per-page access rules are not implemented yet.", + "Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in — but an anonymous request only ever sees published pages, and never their source. Per-page access rules are not implemented yet.\n\nA password-protected page answers with its metadata and `isLocked: true`, its body withheld, until the session satisfies `POST …/unlock` — or unless the requester may edit the page, for whom the password is not a barrier.", tags: ['Pages'], params: { type: 'object', @@ -283,7 +325,10 @@ async function routes(app: FastifyInstance) { locale: req.query.locale, // -> The source is what an editor loads, and editing is not something an anonymous reader does withContent: Boolean(req.query.withContent) && Boolean(actor), - publicOnly: !actor + publicOnly: !actor, + // -> Answered once the page is known, since a hash does not say which page it is yet + unlocked: (pageId) => unlockedFor(req, pageId), + withPassword: mayBypassPassword(req) }) if (!page) { return reply.notFound('This page does not exist.') @@ -292,6 +337,86 @@ async function routes(app: FastifyInstance) { } ) + /** + * UNLOCK PAGE + */ + app.post<{ + Params: { siteId: string; pageIdOrHash: string } + Querystring: { locale?: string } + Body: { password: string } + }>( + '/sites/:siteId/pages/:pageIdOrHash/unlock', + { + schema: { + summary: 'Unlock a password-protected page', + description: + 'Answers with the page, body included, when the password matches — and records the unlock on the session, so that reading the page again does not ask a second time. A wrong password is a 401 and says nothing more; a page with no password on it answers the same way, so that this cannot be used to find out which pages are protected.\n\nCallable without a session, because a protected page is written for readers who have the password rather than an account. Unlocking one is what first gives an anonymous reader a session.\n\nWhoever may edit the page never needs this: they can read the source and remove the password, so `GET` already hands them the body.', + tags: ['Pages'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + }, + pageIdOrHash: { + type: 'string', + oneOf: [{ format: 'uuid' }, { pattern: '^[a-f0-9]+$' }] + } + }, + required: ['siteId', 'pageIdOrHash'] + }, + querystring: { + type: 'object', + properties: { + locale: { + type: 'string', + maxLength: 10 + } + } + }, + body: { + type: 'object', + required: ['password'], + properties: { + password: { + type: 'string', + minLength: 1, + maxLength: 255 + } + } + }, + response: { + 200: { $ref: 'Page#' } + } + } + }, + async (req, reply) => { + const isId = uuidValidate(req.params.pageIdOrHash) + const actor = actorFrom(req) + const page = await WIKI.models.pages.unlockPage({ + siteId: req.params.siteId, + ...(isId ? { id: req.params.pageIdOrHash } : { hash: req.params.pageIdOrHash }), + locale: req.query.locale, + password: req.body.password, + publicOnly: !actor + }) + if (!page) { + return reply.unauthorized('Incorrect password.') + } + /* + Recorded per page rather than as a blanket "this session may read protected pages": each + password is a separate secret, and knowing one says nothing about the others. + + Writing to the session is what creates one for an anonymous reader — `saveUninitialized` is + off, so no row exists until this point. That is the intent: the unlock has to outlive the + request, and it is the reader's own deliberate action that starts it. + */ + req.session.unlockedPages = [...new Set([...(req.session.unlockedPages ?? []), page.id])] + return page + } + ) + /** * CREATE PAGE */ @@ -624,6 +749,14 @@ async function routes(app: FastifyInstance) { if (!actor) { return [] } + /* + An administrator holds all of them, and holds them here too. Filtering their permissions by + name the way the line below does would answer `manage:system` → nothing ending in `:pages` → + that an administrator has no rights over any page, which is the opposite of true. + */ + if (actor.permissions.includes('manage:system')) { + return PAGE_PERMISSIONS + } // FIXME: per-path permission rules are not implemented — a group's page permissions apply to // the whole site, so this returns what the user holds anywhere rather than here. return actor.permissions.filter((p) => p.endsWith(':pages')) diff --git a/backend/api/schemas/page.ts b/backend/api/schemas/page.ts index 4e8fb151c..b5d03fb45 100644 --- a/backend/api/schemas/page.ts +++ b/backend/api/schemas/page.ts @@ -148,7 +148,16 @@ export async function registerSchemas(app: FastifyInstance): Promise { publishEndDate: { type: ['string', 'null'], format: 'date-time' }, isBrowsable: { type: 'boolean' }, isSearchable: { type: 'boolean' }, - password: { type: ['string', 'null'] }, + password: { + type: ['string', 'null'], + description: + 'Only present for a requester who may edit the page — whoever can take the password off it. Absent otherwise, protected page or not.' + }, + isLocked: { + type: 'boolean', + description: + 'The page is password protected and this requester has not entered it, so `content`, `render` and `toc` were withheld. Unlock it with `POST …/unlock`.' + }, relations: { type: 'array', items: { type: 'object', additionalProperties: true } diff --git a/backend/api/schemas/tree.ts b/backend/api/schemas/tree.ts index adb0af18b..7e461c22b 100644 --- a/backend/api/schemas/tree.ts +++ b/backend/api/schemas/tree.ts @@ -76,6 +76,41 @@ export async function registerSchemas(app: FastifyInstance): Promise { } }) + /** + * BROWSE ITEM - One entry of a reader's folder listing, which may be a page and a folder at once + */ + app.addSchema({ + $id: 'BrowseItem', + type: 'object', + properties: { + path: { + type: 'string', + description: + "Slash-separated path of the entry: the page's own URL, and the folder to list on the way down." + }, + fileName: { + type: 'string' + }, + title: { + type: 'string', + description: "The page's title when there is a page here, otherwise the folder's." + }, + icon: { + type: ['string', 'null'], + description: + "The page's icon, as an Iconify reference. Null for a folder with no page at its path." + }, + isPage: { + type: 'boolean', + description: 'Whether there is a page at this path to open.' + }, + isFolder: { + type: 'boolean', + description: 'Whether there is a folder at this path to descend into.' + } + } + }) + /** * FOLDER INPUT - The writable fields of a folder, used for both create and rename */ diff --git a/backend/api/tree.ts b/backend/api/tree.ts index 2ef6e4179..7aedd2f93 100644 --- a/backend/api/tree.ts +++ b/backend/api/tree.ts @@ -186,6 +186,82 @@ async function routes(app: FastifyInstance) { } ) + /** + * BROWSE THE TREE AS A READER + */ + app.get<{ Params: { siteId: string }; Querystring: { path?: string; locale?: string } }>( + '/sites/:siteId/tree/browse', + { + schema: { + summary: 'Browse the tree as a reader', + description: + "Lists one folder for the sidebar's browse menu: the pages a reader may open and the folders holding some, with assets, hidden pages and dead-end folders left out.\n\nA page and a folder can share a path — `/foo/bar` alongside the folder of pages under it — and such a pair comes back as a single entry with both `isPage` and `isFolder` set, since a reader sees one name with two ways in.\n\nReadable without a session, because a wiki is browsed by people who are not logged in — an anonymous request sees only published pages with no password on them, which is exactly what the page view itself would serve them. Requires the site's `browse` feature to be on.", + tags: ['Tree'], + params: siteIdParam, + querystring: { + type: 'object', + properties: { + path: { + type: 'string', + maxLength: 2048, + description: 'Slash-separated path of the folder to list. The site root when absent.' + }, + locale: { + type: 'string', + maxLength: 10, + description: "The site's primary locale when absent." + } + } + }, + response: { + 200: { + description: 'One level of the tree', + type: 'object', + properties: { + path: { + type: 'string', + description: 'The folder that was listed. Empty at the site root.' + }, + title: { + type: 'string', + description: "The folder's title. Empty at the site root, which is not a folder." + }, + truncated: { + type: 'boolean', + description: 'Whether the folder holds more entries than were returned.' + }, + items: { + type: 'array', + items: { $ref: 'BrowseItem#' } + } + } + } + } + } + }, + async (req, reply) => { + const site = WIKI.sites[req.params.siteId] + if (!site) { + return reply.notFound('This site does not exist.') + } + // -> The same setting that hides the sidebar's Browse button, enforced where it counts: with + // browsing off, the tree is not something to hand out one folder at a time either + if (!site.config?.features?.browse) { + return reply.forbidden('Browsing is disabled on this site.') + } + const level = await WIKI.models.tree.browse({ + siteId: req.params.siteId, + path: req.query.path, + locale: req.query.locale ?? defaultLocale(req.params.siteId), + publicOnly: !req.session?.authenticated + }) + if (!level) { + return reply.notFound('This folder does not exist.') + } + return level + } + ) + /** * GET FOLDER */ diff --git a/backend/helpers/common.ts b/backend/helpers/common.ts index f45b5e3a0..569650de2 100644 --- a/backend/helpers/common.ts +++ b/backend/helpers/common.ts @@ -80,6 +80,18 @@ export function generateHash(str: string): string { return crypto.createHash('sha1').update(str).digest('hex') } +/** + * Compare two secrets without leaking which character stopped the comparison. + * + * `===` on strings returns as soon as it finds a difference, and the time that takes is measurable + * across enough attempts. Both sides are digested first because `timingSafeEqual` throws on operands + * of different lengths — the digest is a fixed 32 bytes, so the length of the candidate says nothing. + */ +export function timingSafeCompare(a: string, b: string): boolean { + const digest = (value: string) => crypto.createHash('sha256').update(value).digest() + return crypto.timingSafeEqual(digest(a), digest(b)) +} + /** * Hash a page path the way the frontend does. * diff --git a/backend/locales/en.json b/backend/locales/en.json index a5aab34e1..63abe1e0f 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1391,6 +1391,11 @@ "common.actions.upload": "Upload", "common.actions.view": "View", "common.actions.viewDocs": "View Documentation", + "common.browse.empty": "There is nothing here.", + "common.browse.loadFailed": "Failed to load the contents of this folder.", + "common.browse.openFolder": "Open the {title} folder", + "common.browse.truncated": "This folder holds more entries than can be listed here.", + "common.browse.upOneLevel": "Up one level", "common.clipboard.failure": "Failed to copy to clipboard.", "common.clipboard.success": "Copied to clipboard successfully.", "common.clipboard.uuid": "Copy UUID to clipboard.", @@ -1505,6 +1510,11 @@ "common.page.id": "ID {id}", "common.page.lastEditedBy": "Last edited by", "common.page.loading": "Loading Page...", + "common.page.locked": "This page is password protected.", + "common.page.lockedHint": "Enter the password to read it.", + "common.page.lockedWrongPassword": "That password is not correct.", + "common.page.unlock": "Unlock", + "common.page.unlockTitle": "Unlock this page", "common.page.printFormat": "Print Format", "common.page.private": "Private", "common.page.published": "Published", diff --git a/backend/models/pages.ts b/backend/models/pages.ts index 06a87d6b2..13496b3f2 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -1,6 +1,6 @@ -import { and, eq, isNull, ne, sql } from 'drizzle-orm' +import { and, eq, ne, sql } from 'drizzle-orm' import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts' -import { CustomError, generatePathHash } from '../helpers/common.ts' +import { CustomError, generatePathHash, timingSafeCompare } from '../helpers/common.ts' import type { TocNode } from './rendering.ts' /** What each editor produces, which is what the content column holds. */ @@ -42,7 +42,14 @@ export interface Page { publishEndDate: Date | null isBrowsable: boolean isSearchable: boolean - password: string | null + /** + * The page's password, if it has one. Present only for a requester who may edit the page — see + * `getPage`'s `withPassword`. Absent, rather than null, for everyone else: a reader cannot tell a + * page with no password from one whose password was withheld, and does not need to. + */ + password?: string | null + /** Whether the body was withheld because the page is password protected. See `getPage`. */ + isLocked: boolean relations: any[] tags: string[] toc: TocNode[] @@ -137,8 +144,21 @@ function normalizePath(input: string): string { class Pages { /** * Flatten a row and its blobs into the shape the API returns. + * + * @param locked Withhold the body — the source, the rendered HTML, the table of contents drawn from + * it, and the relation links written onto the page. The metadata stays: a reader + * looking at the lock screen is told what page they are being asked for a password to. + * @param withPassword Include the page's own password. Only for a requester who may edit the page, + * which is the one that has to be able to read it back and save it again. */ - private toPage(row: any, { withContent = false }: { withContent?: boolean } = {}): Page { + private toPage( + row: any, + { + withContent = false, + withPassword = false, + locked = false + }: { withContent?: boolean; withPassword?: boolean; locked?: boolean } = {} + ): Page { const config = row.config ?? {} const scripts = row.scripts ?? {} return { @@ -157,12 +177,13 @@ class Pages { publishEndDate: row.publishEndDate, isBrowsable: row.isBrowsable, isSearchable: row.isSearchable, - password: row.password, - relations: row.relations ?? [], + ...(withPassword ? { password: row.password } : {}), + isLocked: locked, + relations: locked ? [] : (row.relations ?? []), tags: row.tags ?? [], - toc: row.toc ?? [], - render: row.render ?? '', - ...(withContent ? { content: row.content ?? '' } : {}), + toc: locked ? [] : (row.toc ?? []), + render: locked ? '' : (row.render ?? ''), + ...(withContent && !locked ? { content: row.content ?? '' } : {}), allowComments: config.allowComments ?? true, allowContributions: config.allowContributions ?? true, allowRatings: config.allowRatings ?? true, @@ -187,6 +208,24 @@ class Pages { * * The hash is what the frontend addresses a page with — see `generatePathHash` — so this is the * lookup an ordinary page view goes through. + * + * A password-protected page still comes back to a requester who has not unlocked it: the metadata + * is what the lock screen is drawn from. What the password withholds is the body — see `toPage`'s + * `locked`. Anything that puts a page's text in front of a reader has to go through here, or + * through the same check, because the enforcement is this method and not the client. + * + * **The defaults hand over the whole page**, `unlocked` and `withPassword` included, the way they do + * for `publicOnly` beside them: most callers here are a save, a move, a delete or a re-render, and + * none of those is a reader — a save that got a withheld body back would answer its author with an + * empty page, and a re-render would store one. A path that serves a reader has to say so, and there + * are exactly two: the `GET` route, and `unlockPage` below. + * + * @param unlocked Whether the password has been satisfied for this requester. Route-level concern: + * see `unlockedFor` in `api/pages.ts`. A function is called with the page's id once + * the row is in hand, which is what lets a caller answer per page even though it + * asked for the page by path hash. + * @param withPassword Whether to include the password value. For whoever may edit the page — not for + * a reader who just entered it, who needs it no more after that. */ async getPage({ siteId, @@ -194,22 +233,26 @@ class Pages { hash, locale, withContent = false, - publicOnly = false + publicOnly = false, + unlocked = true, + withPassword = true }: { siteId: string id?: string hash?: string locale?: string withContent?: boolean - /** Restrict to what a reader with no session may see: published, and not password protected. */ + /** Restrict to what a reader with no session may see: published pages. */ publicOnly?: boolean + unlocked?: boolean | ((pageId: string) => boolean) + withPassword?: boolean }): Promise { const conditions = [eq(pagesTable.siteId, siteId)] if (publicOnly) { // -> Page-level access rules are not implemented, so this is the whole of it: an anonymous - // reader sees published pages that are not behind a password, and nothing else + // reader sees published pages, and nothing else. A password does not hide a page from them — + // it withholds the body until they enter it, which is what `locked` below does. conditions.push(eq(pagesTable.publishState, 'published')) - conditions.push(isNull(pagesTable.password)) } if (id) { conditions.push(eq(pagesTable.id, id)) @@ -238,6 +281,7 @@ class Pages { if (!row) { return null } + const isUnlocked = typeof unlocked === 'function' ? unlocked(row.page.id) : unlocked return this.toPage( { ...row.page, @@ -245,10 +289,70 @@ class Pages { navigationId: row.navigationId, navigationMode: row.navigationMode }, - { withContent } + { withContent, withPassword, locked: Boolean(row.page.password) && !isUnlocked } ) } + /** + * Check a page's password, and hand the page over if it matches. + * + * Deliberately the only way past the lock: a reader gets the body from here or from a `getPage` the + * route has already marked as unlocked, and never from a flag the browser sent. + * + * @returns The page, its body included, or null when the password is wrong or the page has none — + * the caller cannot tell those apart, and neither can whoever is guessing. + */ + async unlockPage({ + siteId, + id, + hash, + locale, + password, + publicOnly = false + }: { + siteId: string + id?: string + hash?: string + locale?: string + password: string + publicOnly?: boolean + }): Promise { + /* + Asked for as a reader would see it, for two reasons: a wrong guess must not assemble the body in + the first place, and `isLocked` is how this knows there is a password to check at all. + */ + const page = await this.getPage({ + siteId, + id, + hash, + locale, + publicOnly, + unlocked: false, + withPassword: false + }) + if (!page?.isLocked) { + return null + } + const stored = await WIKI.db + .select({ password: pagesTable.password }) + .from(pagesTable) + .where(eq(pagesTable.id, page.id)) + .limit(1) + const expected = stored[0]?.password + if (!expected || !timingSafeCompare(password, expected)) { + return null + } + // -> Unlocked, but still without the password itself: entering it is not the same as being able + // to change it, and the reader has no further use for the value + return this.getPage({ + siteId, + id: page.id, + publicOnly, + unlocked: true, + withPassword: false + }) + } + /** * Create a page. * diff --git a/backend/models/search.ts b/backend/models/search.ts index 4769b3e94..4f6dcb55f 100644 --- a/backend/models/search.ts +++ b/backend/models/search.ts @@ -86,10 +86,15 @@ export interface SearchPagesParams { orderByDirection?: 'asc' | 'desc' offset?: number limit?: number - /** Restrict to what a reader with no session may see: published, and not password protected. */ + /** Restrict to what a reader with no session may see: published pages. */ publicOnly?: boolean /** Whether unpublished pages belong in the results, which is an editor's view of the wiki. */ includeDrafts?: boolean + /** + * Leave out password-protected pages. Set for anyone who would have to enter the password to read + * one, because a result carries an excerpt of the page text — see `highlight` below. + */ + hideProtected?: boolean } /** @@ -212,7 +217,8 @@ class Search { offset = 0, limit = 25, publicOnly = false, - includeDrafts = false + includeDrafts = false, + hideProtected = true }: SearchPagesParams): Promise { const terms = query.trim() const hasQuery = terms.length > 0 @@ -234,10 +240,19 @@ class Search { // -> Matches what a page view shows an anonymous reader, so that search cannot surface a page // that could not then be opened conditions.push(sql`p."publishState" = 'published'`) - conditions.push(sql`p.password IS NULL`) } else if (!includeDrafts) { conditions.push(sql`p."publishState" <> 'draft'`) } + if (hideProtected) { + /* + A result is not just a title: `highlight` below is an excerpt of the page's own text, cut from + `searchContent`. Handing that to someone who would be shown a lock screen on the page itself + would give away through search exactly what the password withholds — so a protected page is + absent from their results entirely rather than present without its excerpt, which would still + confirm that a page matching their terms is there. + */ + conditions.push(sql`p.password IS NULL`) + } if (publishState) { conditions.push(sql`p."publishState" = ${publishState}`) } diff --git a/backend/models/tree.ts b/backend/models/tree.ts index c1902e045..422bb2ca6 100644 --- a/backend/models/tree.ts +++ b/backend/models/tree.ts @@ -1,5 +1,6 @@ -import { and, asc, desc, eq, inArray, ne, or, sql, type SQL } from 'drizzle-orm' -import { tree as treeTable } from '../db/schema.ts' +import { and, asc, desc, eq, exists, inArray, ne, or, sql, type SQL } from 'drizzle-orm' +import { alias, type PgColumn } from 'drizzle-orm/pg-core' +import { pages as pagesTable, tree as treeTable } from '../db/schema.ts' import { CustomError, decodeTreePath, encodeTreePath, generateHash } from '../helpers/common.ts' /** What a tree entry can be. Mirrors the `treeType` enum in the schema. */ @@ -42,6 +43,35 @@ export interface TreeItem { description?: string } +/** + * One row of a browse listing. + * + * A page and a folder can sit at the very same path — `/foo/bar` the page, `/foo/bar/…` the folder of + * pages under it — and a reader thinks of those as one thing with two ways in, so they come back as + * one entry carrying both flags rather than as two rows with the same name. + */ +export interface BrowseItem { + /** Slash-separated path of the entry: the page's own URL, and the folder to list on the way down. */ + path: string + fileName: string + title: string + /** The page's icon, as an Iconify reference. Null for a folder with no page at its path. */ + icon: string | null + isPage: boolean + isFolder: boolean +} + +/** One level of a browse listing: what a folder holds, plus what the folder itself is called. */ +export interface BrowseLevel { + /** The folder that was listed, slash-separated. Empty at the site root. */ + path: string + /** The folder's title. Empty at the site root, which is not a folder and has no row of its own. */ + title: string + items: BrowseItem[] + /** Whether the folder holds more than `MAX_BROWSE` entries, the rest of which were dropped. */ + truncated: boolean +} + /** A raw `tree` row, as the model passes it around internally. */ export interface TreeRow { id: string @@ -65,6 +95,9 @@ const reTitle = /^[^<>"]+$/ const MAX_LIMIT = 1000 const MAX_DEPTH = 10 +/** Ceiling on how many entries one browse level returns. */ +const MAX_BROWSE = 500 + /** How many `name-1`, `name-2`… variants an upload will try before giving up on the name. */ const MAX_NAME_ATTEMPTS = 100 @@ -119,6 +152,32 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem { } } +/** + * What a page has to be for a reader to be shown that it exists. + * + * Deliberately the same rule the page view itself applies (see `pages.getPage`'s `publicOnly`), so + * that a menu never offers a page that would answer 404 — nor hides one that would open. + * + * A password-protected page is listed. It is not hidden but locked: opening it puts the reader in + * front of the unlock prompt, which is exactly where someone who has the password wants to end up, + * and its title is metadata rather than protected content. + * + * The columns come in one by one rather than as a table, because this is applied both to `pages` and + * to an alias of it, and an alias is a different type. + * + * @param publicOnly Restrict to what a reader with no session may see. `isBrowsable` applies either + * way: it is the author saying "not in the tree", not an access rule. + */ +function pageIsVisible( + columns: { isBrowsable: PgColumn; publishState: PgColumn }, + publicOnly: boolean +): (SQL | undefined)[] { + return [ + eq(columns.isBrowsable, true), + ...(publicOnly ? [eq(columns.publishState, 'published')] : []) + ] +} + /** * Tree model * @@ -238,6 +297,150 @@ class Tree { return rows.map(({ row, depth: rowDepth }) => toTreeItem(row as TreeRow, rowDepth, path)) } + /** + * List one folder the way a reader browses it: the pages they may open and the folders worth + * opening, and nothing else. + * + * Not a variant of `getTree()`. That one is the file manager's view — every entry of every kind, + * for someone with permission to manage them. This is the reader's: assets have no place in it, + * a page nobody may see must not appear even as a name, and a folder whose whole contents are + * invisible is a dead end rather than something to offer. + * + * @param path Slash-separated path of the folder to list. The site root when empty. + * @param publicOnly Restrict pages to what a reader with no session may see. See `pageIsVisible`. + * @returns The level, or null when there is no such folder + */ + async browse({ + siteId, + path, + locale, + publicOnly = true + }: { + siteId: string + path?: string | null + locale: string + publicOnly?: boolean + }): Promise { + const encodedPath = encodeTreePath(path) + const basePath = decodeTreePath(encodedPath) ?? '' + + // -> What the level is called. The root is not a folder, so it has no row and no title of its own + // — and a path that is not a folder is nothing this can list. + let title = '' + if (encodedPath) { + const location = splitPath(encodedPath) + const folder = await WIKI.db + .select({ title: treeTable.title }) + .from(treeTable) + .where( + and( + eq(treeTable.siteId, siteId), + eq(treeTable.locale, locale), + eq(treeTable.folderPath, location.folderPath), + eq(treeTable.fileName, location.fileName), + eq(treeTable.type, 'folder') + ) + ) + .limit(1) + if (!folder[0]) { + return null + } + title = folder[0].title + } + + const descendant = alias(treeTable, 'descendantTree') + const descendantPage = alias(pagesTable, 'descendantPage') + // -> Text rather than an ltree operator, so that the child path can be built from a bound prefix + // and the row's own name: `foo.bar.` + `baz` + const childPathPrefix = encodedPath ? `${encodedPath}.` : '' + + /* + Whether a folder holds a page a reader may open, at any depth below it. + + A folder is created for whatever is put in it, so it can end up holding only assets, only + drafts, or nothing at all — descending into any of those lands on an empty menu. `EXISTS` stops + at the first hit, so this costs an index lookup per folder in the level rather than a count. + */ + const holdsVisiblePages = exists( + WIKI.db + .select({ one: sql`1` }) + .from(descendant) + .innerJoin(descendantPage, eq(descendantPage.id, descendant.id)) + .where( + and( + eq(descendant.siteId, treeTable.siteId), + eq(descendant.locale, treeTable.locale), + eq(descendant.type, 'page'), + sql`${descendant.folderPath} <@ (${childPathPrefix}::text || ${treeTable.fileName})::ltree`, + ...pageIsVisible(descendantPage, publicOnly) + ) + ) + ) + + /* + Ordered by file name rather than by title, so that a page and the folder at the same path are + adjacent: the row after `MAX_BROWSE` is dropped, and only a pair straddling that boundary can + lose half of itself. Display order is settled below, once the pairs are merged. + */ + const rows = await WIKI.db + .select({ + type: treeTable.type, + fileName: treeTable.fileName, + title: treeTable.title, + icon: pagesTable.icon, + holdsVisiblePages: sql`${holdsVisiblePages}`.mapWith(Boolean) + }) + .from(treeTable) + .leftJoin(pagesTable, eq(pagesTable.id, treeTable.id)) + .where( + and( + eq(treeTable.siteId, siteId), + eq(treeTable.locale, locale), + eq(treeTable.folderPath, encodedPath), + or( + eq(treeTable.type, 'folder'), + and(eq(treeTable.type, 'page'), ...pageIsVisible(pagesTable, publicOnly)) + ) + ) + ) + .orderBy(asc(treeTable.fileName)) + .limit(MAX_BROWSE + 1) + + const merged = new Map() + for (const row of rows.slice(0, MAX_BROWSE)) { + if (row.type === 'folder' && !row.holdsVisiblePages) { + continue + } + const entry = merged.get(row.fileName) ?? { + path: basePath ? `${basePath}/${row.fileName}` : row.fileName, + fileName: row.fileName, + title: row.title, + icon: null, + isPage: false, + isFolder: false + } + if (row.type === 'folder') { + entry.isFolder = true + } else { + entry.isPage = true + // -> The page is the thing a reader clicks, so it names the row when both exist + entry.title = row.title + entry.icon = row.icon + } + merged.set(row.fileName, entry) + } + + return { + path: basePath, + title, + truncated: rows.length > MAX_BROWSE, + // -> Folders first, as a file browser lists them; an entry that is both belongs with them + items: [...merged.values()].sort((a, b) => + a.isFolder === b.isFolder ? a.title.localeCompare(b.title) : a.isFolder ? -1 : 1 + ) + } + } + /** * A single tree row by ID, or null if there is no such row */ diff --git a/backend/types/fastify.d.ts b/backend/types/fastify.d.ts index 5af90ba16..3aecaa9a5 100644 --- a/backend/types/fastify.d.ts +++ b/backend/types/fastify.d.ts @@ -36,6 +36,12 @@ declare module 'fastify' { permissions?: string[] /** Ids of the groups the user belongs to, which is what per-group visibility is checked against. */ groups?: string[] + /** + * Ids of the password-protected pages this session has entered the password for. Written by the + * unlock route in `api/pages.ts`, and the only thing that opens one for a reader who may not edit + * it — the client is never trusted with that state. + */ + unlockedPages?: string[] } interface FastifyContextConfig { diff --git a/frontend/package.json b/frontend/package.json index 5f791a9cf..11f4a248b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "type": "module", "scripts": { "dev": "vite --force", - "build": "NODE_OPTIONS=--max-old-space-size=8192 vite build --emptyOutDir", + "build": "NODE_ENV=production NODE_OPTIONS=--max-old-space-size=8192 vite build --emptyOutDir", "ncu": "ncu -i", "ncu-u": "ncu -u", "icons": "node scripts/generate-icons.mjs", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 651bc3d24..5630228e6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,10 +4,11 @@ + + + diff --git a/frontend/src/components/NavBrowseMenu.vue b/frontend/src/components/NavBrowseMenu.vue new file mode 100644 index 000000000..c254989d0 --- /dev/null +++ b/frontend/src/components/NavBrowseMenu.vue @@ -0,0 +1,449 @@ + + + + + diff --git a/frontend/src/components/NavSidebar.vue b/frontend/src/components/NavSidebar.vue index 083d6b53e..0baa738ce 100644 --- a/frontend/src/components/NavSidebar.vue +++ b/frontend/src/components/NavSidebar.vue @@ -81,7 +81,12 @@ watch(