feat: add password-protected page capability

scarlett
NGPixel 1 month ago
parent fbf0c1c689
commit c10e988c3a
No known key found for this signature in database

@ -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 `<b>`, 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 `<b>`, 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'))

@ -148,7 +148,16 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
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 }

@ -76,6 +76,41 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
})
/**
* 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
*/

@ -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
*/

@ -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.
*

@ -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",

@ -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<Page | null> {
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<Page | null> {
/*
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.
*

@ -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<SearchPagesResult> {
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}`)
}

@ -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<BrowseLevel | null> {
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<boolean>`${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<string, BrowseItem>()
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
*/

@ -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 {

@ -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",

@ -4,10 +4,11 @@
<w-notifications />
<w-loading-overlay />
<w-dialog-host />
<component :is="DevQuickMenu" v-if="DevQuickMenu" />
</template>
<script setup>
import { reactive, watch } from 'vue'
import { defineAsyncComponent, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@ -26,6 +27,20 @@ import { useUserStore } from '@/stores/user'
/* global siteConfig */
// DEV TOOLS
/*
The dev quick menu, and nothing of it in a release.
`import.meta.env.DEV` is substituted at build time, so a production build sees `false ? … : null`,
drops the branch, and with it the only reference to the dynamic import -- the component is never
emitted as a chunk, not merely never rendered. Keep the import inside this expression for that
reason: a top-level `import` of it would be bundled however it was guarded afterwards.
*/
const DevQuickMenu = import.meta.env.DEV
? defineAsyncComponent(() => import('@/components/DevQuickMenu.vue'))
: null
// DARK MODE
const dark = useDark()

@ -86,6 +86,7 @@ export const BUNDLED_ICONS = {
"la:list": {"body":"<path fill=\"currentColor\" d=\"M4 5v6h6V5zm2 2h2v2H6zm6 0v2h15V7zm-8 6v6h6v-6zm2 2h2v2H6zm6 0v2h15v-2zm-8 6v6h6v-6zm2 2h2v2H6zm6 0v2h15v-2z\"/>","width":32,"height":32},
"la:list-alt": {"body":"<path fill=\"currentColor\" d=\"M10.281 5.281L7 8.563L5.719 7.28L4.28 8.72l2 2l.719.687l.719-.687l4-4zM15 7v2h13V7zm-4.719 6.281L7 16.562l-1.281-1.28l-1.438 1.437l2 2l.719.687l.719-.687l4-4zM15 15v2h13v-2zm-4.719 6.281L7 24.563L5.719 23.28L4.28 24.72l2 2l.719.687l.719-.687l4-4zM15 23v2h13v-2z\"/>","width":32,"height":32},
"la:lock": {"body":"<path fill=\"currentColor\" d=\"M16 3c-3.844 0-7 3.156-7 7v3H6v16h20V13h-3v-3c0-3.844-3.156-7-7-7m0 2c2.754 0 5 2.246 5 5v3H11v-3c0-2.754 2.246-5 5-5M8 15h16v12H8z\"/>","width":32,"height":32},
"la:lock-open": {"body":"<path fill=\"currentColor\" d=\"M16 3c-3.035 0-5.586 1.965-6.625 4.625l1.844.75C11.977 6.434 13.836 5 16 5c2.754 0 5 2.246 5 5v3H6v16h20V13h-3v-3c0-3.844-3.156-7-7-7M8 15h16v12H8z\"/>","width":32,"height":32},
"la:magic": {"body":"<path fill=\"currentColor\" d=\"m20.875 2.563l-.688.75l-1.687 1.78h-3.594v3.5l-1.719 1.813l-.687.719l2.188 2.188L3.03 25l-.719.719l.72.687l3.28 3.282l.688-.72l11.688-11.655l2.187 2.187l.719-.688l1.812-1.718h3.5V13.5l1.782-1.688l.75-.687l-2.532-2.531v-3.5h-3.5zm.031 2.874l1.375 1.375l.313.282h2.312v2.312l.282.313l1.375 1.375l-1.344 1.281l-.313.281v2.438h-2.312l-.282.281l-1.406 1.344l-.812-.813l4.531-4.531l-3.969-3.969l-.718.688l-3.813 3.844l-.844-.844l1.344-1.406l.281-.282V7.094h2.438l.281-.313zm-.25 4.782l1.125 1.156l-15.468 15.5l-1.157-1.156zM19 21v1h-1v2h1v1h2v-1h1v-2h-1v-1zm6 2v2h-2v2h2v2h2v-2h2v-2h-2v-2z\"/>","width":32,"height":32},
"la:microchip": {"body":"<path fill=\"currentColor\" d=\"M7 6v2H3v18h4v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h4V8h-4V6h-2v2h-2V6h-2v2h-2V6h-2v2h-2V6h-2v2H9V6zm-2 4h22v14H5zm3 2c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 16c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m16 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 20c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
"la:minus": {"body":"<path fill=\"currentColor\" d=\"M5 15v2h22v-2z\"/>","width":32,"height":32},
@ -108,7 +109,6 @@ export const BUNDLED_ICONS = {
"la:search": {"body":"<path fill=\"currentColor\" d=\"M19 3C13.488 3 9 7.488 9 13c0 2.395.84 4.59 2.25 6.313L3.281 27.28l1.439 1.44l7.968-7.969A9.92 9.92 0 0 0 19 23c5.512 0 10-4.488 10-10S24.512 3 19 3m0 2c4.43 0 8 3.57 8 8s-3.57 8-8 8s-8-3.57-8-8s3.57-8 8-8\"/>","width":32,"height":32},
"la:server": {"body":"<path fill=\"currentColor\" d=\"M3 6v20h26V6zm2 2h22v4H5zm2 1v2h8V9zm17 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M5 14h22v4H5zm2 1v2h8v-2zm17 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M5 20h22v4H5zm2 1v2h8v-2zm17 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
"la:share": {"body":"<path fill=\"currentColor\" d=\"M19.719 5.281L18.28 6.72L24.563 13H11c-3.855 0-7 3.145-7 7s3.145 7 7 7v-2c-2.773 0-5-2.227-5-5s2.227-5 5-5h13.563l-6.282 6.281l1.438 1.438l8-8l.687-.719l-.687-.719z\"/>","width":32,"height":32},
"la:share-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4c-2.145 0-3.883 1.719-3.969 3.844A9.93 9.93 0 0 0 6 17c0 .172-.008.36 0 .563c-1.184.695-2 1.972-2 3.437c0 2.2 1.8 4 4 4c.574 0 1.129-.121 1.625-.344C11.359 26.113 13.617 27 16 27s4.64-.887 6.375-2.344c.496.223 1.05.344 1.625.344c2.2 0 4-1.8 4-4c0-1.48-.824-2.777-2.031-3.469c.015-.16.031-.324.031-.531a9.93 9.93 0 0 0-6.031-9.156C19.883 5.719 18.145 4 16 4m0 2c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m-3.531 3.844C13.14 11.117 14.469 12 16 12s2.86-.883 3.531-2.156A7.94 7.94 0 0 1 24 17c-2.2 0-4 1.8-4 4c0 .895.309 1.707.813 2.375A8.07 8.07 0 0 1 16 25a8.07 8.07 0 0 1-4.813-1.625A3.92 3.92 0 0 0 12 21c0-2.2-1.8-4-4-4a7.94 7.94 0 0 1 4.469-7.156M8 19c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m16 0c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2\"/>","width":32,"height":32},
"la:sign-in-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4C10.422 4 5.742 7.832 4.406 13H6.47C7.746 8.945 11.53 6 16 6c5.516 0 10 4.484 10 10s-4.484 10-10 10c-4.469 0-8.254-2.945-9.531-7H4.406c1.336 5.168 6.016 9 11.594 9c6.617 0 12-5.383 12-12S22.617 4 16 4m-.656 7.281l-1.438 1.438L16.187 15H4v2h12.188l-2.282 2.281l1.438 1.438l4-4L20.03 16l-.687-.719z\"/>","width":32,"height":32},
"la:sign-out-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12c4.05 0 7.64-2.012 9.813-5.094l-1.625-1.156A9.99 9.99 0 0 1 16 26c-5.535 0-10-4.465-10-10S10.465 6 16 6a9.99 9.99 0 0 1 8.188 4.25l1.625-1.156A11.99 11.99 0 0 0 16 4m7.344 7.281l-1.438 1.438L24.188 15H12v2h12.188l-2.282 2.281l1.438 1.438l4-4L28.03 16l-.687-.719z\"/>","width":32,"height":32},
"la:sitemap": {"body":"<path fill=\"currentColor\" d=\"M12 5v8h3v2H5v4H2v8h8v-8H7v-2h8v2h-3v8h8v-8h-3v-2h8v2h-3v8h8v-8h-3v-4H17v-2h3V5zm2 2h4v4h-4zM4 21h4v4H4zm10 0h4v4h-4zm10 0h4v4h-4z\"/>","width":32,"height":32},

@ -0,0 +1,113 @@
<template>
<!--
A tab hanging off the top edge of the viewport, mostly above it: all that shows at rest is a lip,
enough to find and click without standing in front of the app. Pointing at it (or opening the
menu) slides the whole thing into view.
-->
<button
type="button"
class="dev-tab w-unstyled font-robotomono"
:class="{ 'is-open': state.menuShown }"
aria-label="Developer tools"
:aria-expanded="state.menuShown"
@click="state.menuShown = !state.menuShown">
dev
<!--
Controlled, rather than letting WMenu bind the trigger itself: the tab stays slid down for as
long as the menu is open, which means this component has to know.
-->
<w-menu v-model="state.menuShown" anchor="bottom middle" self="top middle" :offset="[0, 4]">
<w-list dense style="min-width: 260px">
<w-item tag="label">
<w-item-section>
<w-item-label>Dark mode</w-item-label>
<w-item-label caption>
This session only. A reload restores the saved appearance.
</w-item-label>
</w-item-section>
<w-item-section side>
<w-toggle v-model="isDark" />
</w-item-section>
</w-item>
</w-list>
</w-menu>
</button>
</template>
<script setup>
import { computed, reactive } from 'vue'
import { useDark } from '@/composables/dark'
/**
* Developer quick menu mounted only by a dev server, never built into a release. See the guard in
* `App.vue`, which is what keeps this out of the production bundle entirely.
*
* Switches that belong here are the ones worth flipping while looking at a screen, without an
* account, a setting, or a reload: throwaway state that the app should forget on its own. Add rows to
* the list; the tab is deliberately narrow and dumb.
*
* Strings are hardcoded English rather than going through `t()`. Nothing in here should reach the
* translators, and a dev-only key in `en.json` would be shipped to them on the next sync.
*/
// DARK MODE
const dark = useDark()
// DATA
const state = reactive({
menuShown: false
})
// COMPUTED
/*
Writes straight to the body class through the composable, deliberately going around
`userStore.appearance`: that one is persisted per user, and the point of this switch is to try the
other theme on for a minute. Nothing saves it, so the next boot applies the stored appearance as
usual -- as does anything that re-runs `applyTheme()` in App.vue, e.g. changing the real setting.
*/
const isDark = computed({
get: () => dark.isActive,
set: (value) => dark.set(value)
})
</script>
<style scoped lang="scss">
.dev-tab {
position: fixed;
top: -14px;
left: 50%;
/* -> Over everything the app itself can draw: notifications (9000), tooltips (7000), menus (6500+) */
z-index: 9999;
display: flex;
height: 30px;
align-items: flex-end;
/* -> The label sits against the bottom edge, so it stays legible in the 16px lip that shows */
padding: 0 12px 3px;
border-radius: 0 0 5px 5px;
background-color: #7c3aed;
box-shadow: 0 1px 6px rgb(0 0 0 / 0.35);
color: #fff;
font-size: 11px;
line-height: 1;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transform: translateX(-50%);
transition: top 0.15s var(--ease-standard);
}
.dev-tab:hover,
.dev-tab.is-open {
top: 0;
}
@media (prefers-reduced-motion: reduce) {
.dev-tab {
transition-duration: 0.01ms;
}
}
</style>

@ -0,0 +1,449 @@
<template>
<w-menu
ref="menu"
class="translucent-menu"
:anchor="props.anchor"
:self="props.self"
:offset="props.offset"
@show="onShow">
<!-- -> A fixed width: the levels slide sideways past each other, so a panel that resized to its
contents would jump mid-slide. Long titles truncate instead. -->
<div class="browse-menu-panel">
<div class="browse-menu-header flex flex-nowrap items-center">
<!-- -> There is nowhere to go up to from the root, so the button is absent there rather than
sitting disabled. It slides in from the left as its own space opens up, and back out
the same way, so the title beside it moves with it instead of jumping. -->
<transition name="browse-menu-up">
<div v-if="!isRoot" class="browse-menu-up-slot">
<!-- -> The icon comes through the slot rather than the `icon` prop, which is the only way
to size it: WBtn draws a prop icon at WIcon's own default of 24px -->
<w-btn
class="browse-menu-up acrylic-btn"
flat
dense
:disable="state.isLoading"
:aria-label="t(`common.browse.upOneLevel`)"
@click="goUp">
<w-icon name="la:arrow-up" size="xs" />
<w-tooltip>{{ t('common.browse.upOneLevel') }}</w-tooltip>
</w-btn>
</div>
</transition>
<div class="min-w-0 flex-1">
<!-- -> The root has no title of its own, and the site is already named in the sidebar
header directly above this, so there the path stands alone -->
<div v-if="level.title" class="truncate text-sm font-medium">{{ level.title }}</div>
<div class="text-caption truncate opacity-60 font-robotomono">/{{ state.path }}</div>
</div>
</div>
<w-separator />
<div class="browse-menu-track relative overflow-hidden">
<!-- -> Absolute, so that showing it neither shifts the rows down nor re-anchors the panel -->
<w-linear-progress
v-if="state.isLoading"
class="absolute inset-x-0 top-0 z-10"
indeterminate
size="2px" />
<transition :name="`browse-menu-${state.direction}`">
<div :key="state.path" class="browse-menu-level py-1">
<div
v-for="item of level.items"
:key="item.path"
class="browse-menu-row flex flex-nowrap items-stretch">
<!--
One row per name, whichever of the two kinds it is -- and both at once for a page
that also has a folder of pages under it. There the label opens the page and the
chevron beside it descends, so neither way in hides the other.
-->
<router-link
v-if="item.isPage"
class="browse-menu-target"
:to="`/${item.path}`"
@click="menu?.hide()">
<w-icon :name="item.icon || `la:file-alt`" size="xs" class="shrink-0 opacity-70" />
<span class="truncate">{{ item.title }}</span>
</router-link>
<!-- -> The File Manager's folder, so a folder looks the same wherever the wiki draws
one. Full strength, unlike the line icons around it: it is a colour image, and
dimming it only washes the yellow out. -->
<button v-else type="button" class="browse-menu-target" @click="descend(item)">
<w-icon name="img:/_assets/icons/fluent-folder.svg" size="xs" class="shrink-0" />
<span class="truncate">{{ item.title }}</span>
<w-space />
<w-icon name="la:angle-right" size="xs" class="shrink-0 opacity-40" />
</button>
<button
v-if="item.isPage && item.isFolder"
type="button"
class="browse-menu-into"
:aria-label="t(`common.browse.openFolder`, { title: item.title })"
@click="descend(item)">
<w-tooltip>{{ t('common.browse.openFolder', { title: item.title }) }}</w-tooltip>
<w-icon name="la:angle-right" size="xs" class="opacity-70" />
</button>
</div>
<div v-if="level.items.length < 1" class="browse-menu-note">
{{ t('common.browse.empty') }}
</div>
<div v-if="level.truncated" class="browse-menu-note">
{{ t('common.browse.truncated') }}
</div>
</div>
</transition>
</div>
</div>
</w-menu>
</template>
<script setup>
import { computed, nextTick, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/notify'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
/**
* The sidebar's Browse menu: one folder of the site at a time, as a reader walks it.
*
* Opens on the folder holding the page being read, and slides a level sideways on the way in or out
* rather than nesting submenus -- a wiki tree is deep, and cascading panels would run off the screen
* a couple of levels down.
*
* What it lists comes from `tree/browse`, which decides what a reader may see; nothing here filters,
* so there is no version of this menu that shows more than the server was willing to hand over.
*/
// PROPS
const props = defineProps({
anchor: {
type: String,
default: 'bottom left'
},
self: {
type: String,
default: 'top left'
},
offset: {
type: Array,
default: () => [0, 0]
}
})
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// REFS
const menu = ref(null)
// DATA
const EMPTY_LEVEL = { title: '', items: [], truncated: false }
const state = reactive({
/** Slash-separated path of the folder being listed. Empty at the site root. */
path: '',
/** Which way the next level slides in from. */
direction: 'forward',
isLoading: false,
/** Levels already fetched, by path, so that walking back up is instant. */
levels: {}
})
// COMPUTED
const level = computed(() => state.levels[state.path] ?? EMPTY_LEVEL)
const isRoot = computed(() => !state.path)
// METHODS
/** Opens on the folder holding the current page, with whatever was cached from last time dropped. */
function onShow() {
state.levels = {}
state.direction = 'forward'
state.path = pageStore.folderPath
load(state.path)
}
/*
Which fetch is the current one. Reopening the menu drops the cache and asks again, so a request
from the previous open can still land afterwards and it must not be the one that decides the
progress bar is finished. The level it writes is keyed by its own path, so it is harmless otherwise.
*/
let latestRequest = 0
async function load(path) {
if (state.levels[path]) {
return true
}
const request = ++latestRequest
state.isLoading = true
try {
const data = await API_CLIENT.get(`sites/${siteStore.id}/tree/browse`, {
searchParams: {
path,
locale: pageStore.locale
}
}).json()
state.levels[path] = {
title: data.title ?? '',
items: data.items ?? [],
truncated: Boolean(data.truncated)
}
return true
} catch (err) {
notify({
type: 'negative',
message: t('common.browse.loadFailed'),
caption: err.message
})
return false
} finally {
if (request === latestRequest) {
state.isLoading = false
}
}
}
/**
* Moves to another level: the contents are fetched first, so the slide reveals the rows already in
* place rather than an empty panel that fills in afterwards.
*/
async function moveTo(path, direction) {
if (state.isLoading || path === state.path) {
return
}
if (!(await load(path))) {
return
}
state.direction = direction
state.path = path
// -> The panel is anchored by its top edge, and a level of a different length moves the other one
await nextTick()
menu.value?.updatePosition()
}
function descend(item) {
moveTo(item.path, 'forward')
}
function goUp() {
moveTo(state.path.split('/').slice(0, -1).join('/'), 'back')
}
</script>
<style scoped lang="scss">
.browse-menu-panel {
width: 270px;
}
/*
A fixed height, because the header's contents are not the same on every level: the root has no
title line and no up button, and letting the row size itself moved everything below it by the
difference each time a level changed. 52px is the two lines it holds at most -- a 20px title and a
20px path -- plus the space around them.
That leaves 12px above and below the 28px button, which is where the 12px beside it comes from: the
gap around it reads as even only if all four sides match. The left one is this padding, the right
one is the slot's own margin below.
*/
.browse-menu-header {
height: 52px;
padding: 0 8px 0 12px;
}
/*
A square target. `dense` puts 4px around the 18px icon, and pinning the min-width to the 28px the
height comes to is what keeps the box from ending up wider or narrower than it is tall, rather than
leaving the square to be a coincidence of two component defaults.
*/
.browse-menu-up {
min-width: 28px;
}
/* -> Dimmed at rest: it is the one control up here, and it should not compete with the folder name
beside it. Full strength once the pointer is on it. */
.browse-menu-up .w-icon {
opacity: 0.7;
}
.browse-menu-up:hover .w-icon {
opacity: 1;
}
/*
The button's footprint, as its own element: `width` is what animates, so the space closes up with
the button rather than after it. Sized to match the button, which fills it exactly, plus the gap
that separates it from the title -- which belongs to the button and so goes when it goes.
*/
.browse-menu-up-slot {
flex: none;
width: 28px;
margin-right: 12px;
}
/*
Clipped only while it moves. At rest the slot must not clip, or it would cut off the focus ring
WBtn draws just outside its own box.
*/
.browse-menu-up-enter-active,
.browse-menu-up-leave-active {
overflow: hidden;
transition:
width 0.18s var(--ease-standard),
margin-right 0.18s var(--ease-standard),
opacity 0.18s var(--ease-standard);
}
/* -> The slide itself is on the button: a percentage transform on the slot would resolve against a
width that is zero at exactly that moment, and move nothing */
.browse-menu-up-enter-active .browse-menu-up,
.browse-menu-up-leave-active .browse-menu-up {
transition: transform 0.18s var(--ease-standard);
}
.browse-menu-up-enter-from,
.browse-menu-up-leave-to {
width: 0;
margin-right: 0;
opacity: 0;
}
.browse-menu-up-enter-from .browse-menu-up,
.browse-menu-up-leave-to .browse-menu-up {
transform: translateX(-100%);
}
@media (prefers-reduced-motion: reduce) {
.browse-menu-up-enter-active,
.browse-menu-up-leave-active,
.browse-menu-up-enter-active .browse-menu-up,
.browse-menu-up-leave-active .browse-menu-up {
transition-duration: 0.01ms;
}
}
.browse-menu-target {
display: flex;
flex: 1 1 0;
min-width: 0;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-size: 13px;
text-align: left;
color: inherit;
text-decoration: none;
cursor: pointer;
}
/*
The page being read, marked without a line of script: a `router-link` to the current route carries
`router-link-exact-active` itself, and the menu opens on that page's own folder so it is normally
one of the rows on screen.
*/
.browse-menu-target.router-link-exact-active {
color: var(--color-primary);
font-weight: 500;
@at-root .body--dark & {
color: var(--color-primary-light);
}
}
.browse-menu-into {
display: flex;
align-items: center;
padding: 0 10px;
cursor: pointer;
/* -> The seam that says the row has two hit targets rather than one */
border-left: 1px solid rgb(0 0 0 / 0.08);
@at-root .body--dark & {
border-left-color: rgb(255 255 255 / 0.12);
}
}
/* Same tints WItem uses, so a row here feels like a row anywhere else */
.browse-menu-target,
.browse-menu-into {
&:hover {
background-color: rgb(0 0 0 / 0.08);
}
&:active {
background-color: rgb(0 0 0 / 0.14);
}
@at-root .body--dark & {
&:hover {
background-color: rgb(255 255 255 / 0.14);
}
&:active {
background-color: rgb(255 255 255 / 0.22);
}
}
}
.browse-menu-note {
padding: 8px 12px;
font-size: 12px;
opacity: 0.6;
}
/*
The slide.
The incoming level stays in flow, so the panel takes its height immediately; the outgoing one is
taken out of flow for the duration, which is what lets the two overlap while they cross.
*/
.browse-menu-level {
width: 100%;
}
.browse-menu-forward-enter-active,
.browse-menu-forward-leave-active,
.browse-menu-back-enter-active,
.browse-menu-back-leave-active {
transition:
transform 0.18s var(--ease-standard),
opacity 0.18s var(--ease-standard);
}
.browse-menu-forward-leave-active,
.browse-menu-back-leave-active {
position: absolute;
top: 0;
left: 0;
}
.browse-menu-forward-enter-from,
.browse-menu-back-leave-to {
transform: translateX(100%);
opacity: 0;
}
.browse-menu-forward-leave-to,
.browse-menu-back-enter-from {
transform: translateX(-100%);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.browse-menu-forward-enter-active,
.browse-menu-forward-leave-active,
.browse-menu-back-enter-active,
.browse-menu-back-leave-active {
transition-duration: 0.01ms;
}
}
</style>

@ -81,7 +81,12 @@ watch(
<style lang="scss">
.sidebar-nav {
border-top: 1px solid rgba(255, 255, 255, 0.15);
height: calc(100% - 38px - 24px);
/* -> Fills whatever the drawer's flex column has left over, rather than subtracting the action bar
and footer bar by hand: both are conditional, so a fixed `calc()` left dead space at the bottom
for an anonymous reader (no footer bar) and for a site with no action bar at all. `min-height: 0`
is what lets it shrink below its content so the scroll area actually scrolls. */
flex: 1 1 0;
min-height: 0;
&-list > .w-separator {
margin-top: 10px;

@ -86,17 +86,6 @@
@click="notImplemented">
<w-tooltip>Bookmark Page</w-tooltip>
</w-btn>
<w-btn
class="ml-4"
v-if="!pageStore.isHome"
flat
dense
icon="la:share-alt"
color="grey"
aria-label="Share">
<w-tooltip>Share</w-tooltip>
<social-sharing-menu />
</w-btn>
<w-btn
class="ml-4"
v-if="siteStore.theme.showPrintBtn"
@ -217,7 +206,6 @@ import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import IconPickerDialog from '@/components/IconPickerDialog.vue'
import SocialSharingMenu from '@/components/SocialSharingMenu.vue'
// STORES

@ -318,9 +318,14 @@
unchecked-icon="la:times" />
</div>
<div v-if="state.requirePassword" style="padding-left: 40px">
<!-- -> Masked, with WInput's own reveal toggle: this is a secret to hand out rather than
one to remember, so the author has to be able to read back what they typed -->
<w-input
ref="iptPagePassword"
v-model="pageStore.password"
type="password"
revealable
autocomplete="off"
:label="t(`editor.props.password`)"
:hint="t(`editor.props.passwordHint`)"
outlined

@ -0,0 +1,122 @@
<template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card style="width: 450px; max-width: 90vw">
<w-card-section class="card-header">
<w-icon name="la:lock" size="sm" class="mr-2" />
<span>{{ t('common.page.unlockTitle') }}</span>
</w-card-section>
<w-form ref="unlockForm" class="py-2" @submit="unlock">
<w-item>
<blueprint-icon icon="key" />
<w-item-section>
<w-input
ref="iptPassword"
v-model="state.password"
outlined
dense
type="password"
autocomplete="current-password"
revealable
hide-bottom-space
:label="t(`auth.fields.password`)"
:rules="passwordValidation"
lazy-rules="ondemand" />
</w-item-section>
</w-item>
</w-form>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="grey"
padding="xs md"
@click="onDialogCancel" />
<w-btn
unelevated
icon="la:lock-open"
:label="t(`common.page.unlock`)"
color="primary"
padding="xs md"
:loading="state.isLoading"
@click="unlock" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { usePageStore } from '@/stores/page'
/**
* Prompts for a password-protected page's password and asks the server to open it.
*
* Confirming resolves the dialog only once the server has accepted the password and the page store
* holds the content so whoever opened this can take `ok` to mean the page is readable, and a wrong
* guess leaves the prompt up to try again.
*/
// EMITS
defineEmits([...dialogComponentEmits])
// REFS
const iptPassword = ref(null)
const unlockForm = ref(null)
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
autofocus: () => iptPassword.value
})
// STORES
const pageStore = usePageStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
password: '',
isLoading: false
})
// VALIDATION RULES
const passwordValidation = [(val) => val.length > 0 || t('auth.errors.missingPassword')]
// METHODS
async function unlock() {
state.isLoading = true
try {
if (!(await unlockForm.value.validate(true))) {
throw new Error(t('auth.errors.missingPassword'))
}
await pageStore.pageUnlock(state.password)
onDialogOK()
} catch (err) {
notify({
type: 'negative',
// -> A rejected password is the expected outcome here, not a failure to report as one
message: err.response?.status === 401 ? t('common.page.lockedWrongPassword') : err.message
})
// -> Cleared and refocused, because the next thing a reader does is type it again
state.password = ''
iptPassword.value?.focus()
}
state.isLoading = false
}
</script>

@ -1,142 +0,0 @@
<template>
<w-menu
auto-close
anchor="bottom middle"
self="top middle"
@show="menuShown"
@before-hide="menuHidden">
<w-list dense padding>
<w-item clickable ref="copyUrlButton">
<w-item-section class="items-center" avatar>
<w-icon color="grey" name="la:clipboard" size="sm" />
</w-item-section>
<w-item-section class="pr-4">Copy URL</w-item-section>
</w-item>
<w-item
clickable
tag="a"
:href="`mailto:?subject=` + encodeURIComponent(props.title) + `&body=` + encodeURIComponent(urlFormatted) + `%0D%0A%0D%0A` + encodeURIComponent(props.description)"
target="_blank">
<w-item-section class="items-center" avatar>
<w-icon color="grey" name="la:envelope" size="sm" />
</w-item-section>
<w-item-section class="pr-4">Email</w-item-section>
</w-item>
</w-list>
</w-menu>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/notify'
import ClipboardJS from 'clipboard'
// PROPS
const props = defineProps({
url: {
type: String,
default: null
},
title: {
type: String,
default: 'Untitled Page'
},
description: {
type: String,
default: ''
}
})
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
width: 626,
height: 436,
left: 0,
top: 0,
clip: null
})
let clip = null
const copyUrlButton = ref(null)
// COMPUTED
const urlFormatted = computed(() => {
if (!import.meta.env.SSR) {
return props.url ? props.url : window.location.href
} else {
return ''
}
})
// METHODS
function openSocialPop(url) {
const popupWindow = window.open(
url,
'sharer',
`status=no,height=${state.height},width=${state.width},resizable=yes,left=${state.left},top=${state.top},screenX=${state.left},screenY=${state.top},toolbar=no,menubar=no,scrollbars=no,location=no,directories=no`
)
popupWindow.focus()
}
function menuShown(ev) {
clip = new ClipboardJS(copyUrlButton.value.$el, {
text: () => {
return urlFormatted.value
}
})
clip.on('success', () => {
notify({
message: 'URL copied successfully',
icon: 'la:clipboard'
})
})
clip.on('error', () => {
notify({
type: 'negative',
message: 'Failed to copy to clipboard'
})
})
}
function menuHidden(ev) {
clip.destroy()
}
// MOUNTED
onMounted(() => {
/**
* Center the popup on dual screens
* http://stackoverflow.com/questions/4068373/center-a-popup-window-on-screen/32261263
*/
const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left
const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top
const width = window.innerWidth
? window.innerWidth
: document.documentElement.clientWidth
? document.documentElement.clientWidth
: screen.width
const height = window.innerHeight
? window.innerHeight
: document.documentElement.clientHeight
? document.documentElement.clientHeight
: screen.height
state.left = width / 2 - state.width / 2 + dualScreenLeft
state.top = height / 2 - state.height / 2 + dualScreenTop
})
</script>

@ -83,14 +83,21 @@
@blur="onBlur"
@keyup.enter="$emit('keyup:enter', $event)" />
<!--
`mr-1` on the button rather than more padding on the control: the padding is what every
trailing control shares -- the clear cross, an `append` slot -- and this is about the eye,
which reads cramped against the field's edge at the row's own 8px.
-->
<button
v-if="revealable && type === 'password'"
type="button"
class="w-unstyled shrink-0 cursor-pointer opacity-60 hover:opacity-100"
class="w-unstyled mr-1 shrink-0 cursor-pointer opacity-60 hover:opacity-100"
:aria-label="isRevealed ? hideLabel : revealLabel"
:aria-pressed="String(isRevealed)"
@click="isRevealed = !isRevealed">
<w-icon :name="isRevealed ? 'mdi:eye-off' : 'mdi:eye'" />
<!-- -> A size of its own rather than the control's 1em: at the field's 14px the eye came out
smaller than the text it sits beside, which is not much of a target to aim at -->
<w-icon :name="isRevealed ? 'mdi:eye-off' : 'mdi:eye'" size="xs" />
</button>
<button

@ -21,15 +21,19 @@
<w-tooltip anchor="center right" self="center left">Switch Locale</w-tooltip>
</w-btn>
<w-btn
v-if="canBrowse"
class="py-4"
flat
icon="la:sitemap"
color="white"
aria-label="Browse"
@click="notImplemented">
<w-tooltip anchor="center right" self="center left">Browse</w-tooltip>
:aria-label="t(`common.sidebar.browse`)">
<nav-browse-menu anchor="top right" self="top left" />
<w-tooltip anchor="center right" self="center left">
{{ t('common.sidebar.browse') }}
</w-tooltip>
</w-btn>
<w-separator class="my-2" inset dark />
<!-- -> Nothing to divide from Bookmarks when neither button above it renders -->
<w-separator v-if="siteStore.locales.showMenu || canBrowse" class="my-2" inset dark />
<w-btn
class="py-4"
flat
@ -57,8 +61,9 @@
</w-btn>
</div>
<template v-else>
<div class="sidebar-actions flex flex-nowrap items-stretch">
<!-- -> Both the button and its separator go, so Browse spans the row on its own -->
<div v-if="showSidebarActions" class="sidebar-actions flex flex-nowrap items-stretch">
<!-- -> Either button takes the whole row when the other one is off, and the separator only
exists to divide the two, so it goes with them -->
<template v-if="siteStore.locales.showMenu">
<w-btn
class="flex-1 px-2"
@ -70,17 +75,19 @@
size="sm">
<locale-selector-menu :offset="[-5, 5]" />
</w-btn>
<w-separator vertical />
<w-separator v-if="canBrowse" vertical />
</template>
<w-btn
v-if="canBrowse"
class="flex-1 px-2"
flat
dense
icon="la:sitemap"
label="Browse"
aria-label="Browse"
size="sm"
@click="notImplemented" />
:label="t(`common.sidebar.browse`)"
:aria-label="t(`common.sidebar.browse`)"
size="sm">
<nav-browse-menu :offset="[-5, 5]" />
</w-btn>
</div>
<nav-sidebar />
<w-bar v-if="userStore.authenticated" class="sidebar-footerbtns text-white" dense>
@ -137,6 +144,7 @@ import { useUserStore } from '@/stores/user'
import FooterNav from '@/components/FooterNav.vue'
import HeaderNav from '@/components/HeaderNav.vue'
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
import NavBrowseMenu from '@/components/NavBrowseMenu.vue'
import NavSidebar from '@/components/NavSidebar.vue'
import NavEditMenu from '@/components/NavEditMenu.vue'
import MainOverlayDialog from '@/components/MainOverlayDialog.vue'
@ -214,6 +222,13 @@ const scrollerAnchorX = computed(() => {
: `${sidebarWidth.value}px`
})
// -> The "Allow Browsing" site feature (admin/general): with it off the tree browser is not something
// a reader can reach, so the button that opens it does not render
const canBrowse = computed(() => siteStore.features.browse)
// -> The action bar holds only the locale menu and Browse; with both off it would be an empty strip
const showSidebarActions = computed(() => siteStore.locales.showMenu || canBrowse.value)
// -> Saving from this menu needs manage:navigation, so offering it to anyone else only produces a
// permission error once they press Save
const canEditNav = computed(() => {

@ -31,6 +31,23 @@
class="min-w-0 flex-1"
:style="siteStore.theme.tocPosition === `left` ? `order: 2;` : `order: 1;`">
<component :is="editorComponents[editorStore.editor]" v-if="editorStore.isActive" />
<!--
The lock screen, in place of the article. There is nothing to hide here: the server sent no
body at all, so this is the whole of what arrived for a protected page.
-->
<div v-else-if="pageStore.isLocked" class="page-locked">
<w-icon class="page-locked-icon" name="la:lock" />
<div class="text-h6">{{ t('common.page.locked') }}</div>
<div class="text-body2 mt-1 opacity-60">{{ t('common.page.lockedHint') }}</div>
<w-btn
class="mt-6"
unelevated
icon="la:lock-open"
color="primary"
padding="xs lg"
:label="t(`common.page.unlock`)"
@click="promptUnlock" />
</div>
<w-scroll-area class="page-container-scrl" v-else style="height: 100%">
<div class="p-4">
<div class="page-contents" ref="pageContents" v-html="pageStore.render" />
@ -124,14 +141,19 @@
<div class="text-caption text-grey-7">{{ t('common.page.tags') }}</div>
<w-space />
<!--
Always rendered, hidden with `visibility` rather than removed: `display: none` took the
row's height with it, so the heading jumped 6px the moment the pointer arrived.
`visibility` also keeps it out of the tab order and out of hit-testing while hidden,
which `opacity: 0` on its own would not.
Rendered for whoever may save the page, and hidden with `visibility` rather than
removed as the pointer comes and goes: `display: none` took the row's height with it,
so the heading jumped 6px the moment the pointer arrived. `visibility` also keeps it
out of the tab order and out of hit-testing while hidden, which `opacity: 0` on its own
would not.
It stays put while editing, because that is when it is the way back out.
A reader gets no button at all -- `v-if`, not the same `visibility` treatment, because
for them it is not a control that happens to be out of sight.
-->
<w-btn
v-if="canEditPage"
class="tags-edit-btn"
:class="{ 'is-hidden': !state.tagEditMode && !state.showTagsEditBtn }"
size="sm"
@ -179,6 +201,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
import { dialog } from '@/composables/dialog'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
@ -197,6 +220,7 @@ import PageActionsCol from '@/components/PageActionsCol.vue'
import PageHeader from '@/components/PageHeader.vue'
import PageTags from '@/components/PageTags.vue'
import PageToc from '@/components/PageToc.vue'
import PageUnlockDialog from '@/components/PageUnlockDialog.vue'
import SideDialog from '@/components/SideDialog.vue'
const editorComponents = {
@ -279,6 +303,23 @@ const showToc = computed(() => {
}).length > 0
)
})
/*
Whether this user may save a change to the page, which is what editing the tags amounts to -- the tags
go up with the rest of the page rather than through an endpoint of their own. So the test is the pair
the PATCH route accepts: `write:pages` or `manage:pages`.
Read off `pagePermissions` rather than through `userStore.can()`, which would answer true for
everybody: `can()` also consults `userStore.permissions`, and `users/whoami` still fills that with a
hardcoded `['manage:system']` for every session -- a TODO in `api/users.ts`. `pagePermissions` comes
from `pages/userPermissions`, which reads what the session actually holds. Once whoami is fixed this
check needs no change: that route stays the authority on what a user may do to a page.
*/
const canEditPage = computed(() =>
['write:pages', 'manage:pages'].some((permission) =>
userStore.pagePermissions.includes(permission)
)
)
const relationsLeft = computed(() => {
return pageStore.relations ? pageStore.relations.filter((r) => r.position === 'left') : []
})
@ -331,6 +372,22 @@ watch(
{ immediate: true }
)
/*
A protected page asks for its password the moment it arrives: the reader followed a link to read it,
and making them press a button first would only add a step. Keyed on the page rather than on the
flag, so dismissing the prompt does not immediately reopen it -- the lock screen's own button is the
way back in -- while walking to another protected page prompts again.
*/
watch(
() => (pageStore.isLocked ? pageStore.id : null),
(lockedPageId) => {
if (lockedPageId) {
promptUnlock()
}
},
{ immediate: true }
)
watch(
() => route.path,
async (newValue) => {
@ -386,9 +443,10 @@ watch(
isActive: false
})
}
// -> Load Blocks
// -> Load Blocks. `?.` because a locked page draws its lock screen in place of the article, so
// there is no content element to scan -- and nothing in it to scan for.
nextTick(() => {
for (const block of pageContents.value.querySelectorAll(':not(:defined)')) {
for (const block of pageContents.value?.querySelectorAll(':not(:defined)') ?? []) {
commonStore.loadBlocks([block.tagName.toLowerCase()])
}
})
@ -418,9 +476,49 @@ watch(
},
{ immediate: true }
)
// METHODS
/** Asks for the page's password. Opened on arrival, and again from the lock screen's own button. */
function promptUnlock() {
dialog({ component: PageUnlockDialog })
}
</script>
<style lang="scss">
.page-locked {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
/* -> Off dead centre: the text reads better a little above the middle of the column */
padding: 0 24px 10vh;
text-align: center;
/*
Stated per theme, as everything else in this column is: the article's own colours come from
`_page-contents.scss`, so a plain block dropped in beside it inherits the document's black and
goes invisible on the dark surface. The icon below takes its colour from here as well.
*/
@at-root .body--light & {
color: $grey-9;
}
@at-root .body--dark & {
color: #fff;
}
}
/*
Large and faint. It is the illustration on an otherwise empty column, not something to look at -- the
sentence under it is what the reader is here to read.
*/
.page-locked-icon {
margin-bottom: 24px;
font-size: 96px;
opacity: 0.12;
}
.page-breadcrumbs {
@at-root .body--light & {
background: linear-gradient(to bottom, $grey-1 0%, $grey-3 100%);

@ -38,6 +38,12 @@ export const usePageStore = defineStore('page', {
icon: DEFAULT_PAGE_ICON,
id: '',
isBrowsable: true,
/**
* Whether the server withheld this page's body because it is password protected and this reader
* has not entered the password. `render`, `toc` and `content` are empty while it is set the API
* never sent them so nothing here can display a locked page by mistake.
*/
isLocked: false,
isSearchable: true,
locale: 'en',
navigationId: null,
@ -132,6 +138,34 @@ export const usePageStore = defineStore('page', {
throw err
}
},
/**
* PAGE - UNLOCK
*
* Hands a password for a protected page to the server, which answers with the page body
* included when it matches. The reply is what fills the content in, rather than this store
* flipping `isLocked` and re-reading a page it already had: there is nothing here to unlock, the
* body was never sent.
*
* The server also remembers the unlock for the session, so navigating away and back does not ask
* again.
*
* @param {string} password
* @throws When the password is wrong (401) or the request fails; the caller reports it.
*/
async pageUnlock(password) {
const siteStore = useSiteStore()
const pageData = await API_CLIENT.post(`sites/${siteStore.id}/pages/${this.id}/unlock`, {
json: { password }
}).json()
this.$patch({
...pageData,
contentLoaded: Object.hasOwn(pageData, 'content'),
relations: pageData.relations.map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
),
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
},
/**
* PAGE - GET PATH FROM ALIAS
*/

@ -61,6 +61,7 @@ export const useSiteStore = defineStore('site', {
overlay: null,
overlayOpts: {},
features: {
browse: false,
profile: false,
ratingsMode: 'off',
reasonForChange: 'required',

Loading…
Cancel
Save