feat: view page version by ID

This commit is contained in:
NGPixel committed 2026-09-07 02:46:44 -04:00
1 parent acb555eaa7
commit 50adab2eac
25 files changed
+1820 -485

No files matched your search

+8
View File
@@ -10,6 +10,14 @@
"categories": {
"correctness": "error"
},
"rules": {
// Not in `correctness`, so it has to be named. Free here -- the backend reports none, `WIKI` being
// declared above and everything else imported -- and on for the same reason as the frontend's:
// a name used but never imported is a runtime error that no build step here would catch, there
// being no build step. `npm run typecheck` finds it too, but that is a separate command; this
// puts it in the lint everyone runs.
"no-undef": "error"
},
"ignorePatterns": [
"node_modules/**",
"**/*.min.js",
+67
View File
@@ -1263,6 +1263,73 @@ async function routes(app: FastifyInstance) {
}
)
/**
* PAGE VERSION BY ID
*/
app.get<{ Params: { siteId: string; versionId: string } }>(
'/sites/:siteId/versions/:versionId',
{
// -> Checked per page below, for the same reason as the history routes above
schema: {
summary: 'Get a page version by its ID alone',
description:
'The same version as the history route, addressed WITHOUT naming the page — what a `/_version/<id>` link resolves. The page it came off is named in the reply, since that is what the reader is asking to be told.\n\nNeeds `read:history` and the ability to read that page, on the same terms as the history list. A version whose page has since been deleted answers 404: the permissions that would decide who may read it are page rules, and there is no longer a page to check them against.',
tags: ['Pages'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
versionId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId', 'versionId']
},
response: {
200: { $ref: 'PageVersionById#' }
}
}
},
async (req, reply) => {
const version = await WIKI.models.pageHistory.getVersionById(
req.params.siteId,
req.params.versionId
)
if (!version) {
return reply.notFound('This version does not exist.')
}
/*
The page as it stands, which is what carries the access rules — a version has none of its own.
Note the rules are matched against the page's CURRENT path, not the path the version was
written at: a page that has moved is one page, and who may read its history is a question about
where it is now.
*/
const page = await loadReadablePage(req, req.params.siteId, version.pageId)
if (!page) {
return reply.notFound('This version does not exist.')
}
if (!mayOnPage(req, 'read:history', page)) {
return reply.forbidden("You are not allowed to read this page's history.")
}
if (page.isLocked) {
return reply.forbidden('This page is password protected.')
}
/*
The page's CURRENT path and locale, alongside the historical ones already on the version.
Both are here so a reader looking at a snapshot can be sent to the page as it stands, and
`version.path` cannot do that job: it is where the page was WHEN the version was written, so
for a page that has since moved it points at nothing. Free to include — the page is already
loaded, one line above, to decide whether this reader may be here at all.
*/
return { ...version, pagePath: page.path, pageLocale: page.locale }
}
)
/**
* RESOLVE ALIAS
*/
+33 -1
View File
@@ -430,7 +430,39 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'object',
additionalProperties: true,
description:
'The rest of the page as it stood: description, icon, tags, publish state and dates, relations, scripts, config, editor and content type.'
'The rest of the page as it stood: description, icon, tags, publish state and dates, relations, scripts, config, editor, content type and the contents list (`toc`).'
}
}
}
]
})
/**
* PAGE VERSION BY ID - The same again, saying which page it came off
*/
app.addSchema({
$id: 'PageVersionById',
type: 'object',
allOf: [
{ $ref: 'PageHistoryVersion#' },
{
type: 'object',
properties: {
pageId: {
type: 'string',
format: 'uuid',
description:
'The page this is a version of. Present because a version URL names only the version, so this is how the reader is told what they are looking at a snapshot OF.'
},
pagePath: {
type: 'string',
description:
'Where that page is NOW — which is what a link to the live page has to be built from. Not to be confused with `path`, which is where it was when this version was written.'
},
pageLocale: {
type: 'string',
description:
"The locale that page is in now, needed to prefix the link on a site that brackets its URLs by locale. Its historical counterpart is not recorded on the version's own fields."
}
}
}
+2 -2
View File
@@ -43,8 +43,8 @@ const nanoid = customAlphabet('1234567890abcdef', 10)
* First path segments the SERVER itself answers — every prefix registered in `initHTTPServer`.
*
* Spelled out rather than tested with `isPageUrl`, because a leading underscore does not mean the
* server: the frontend router owns `/_admin`, `/_profile`, `/_inbox`, `/_search`, `/_create`, `/_edit`
* and `/_error` too, and those have to reach the app shell like any page path. The distinction the
* server: the frontend router owns `/_admin`, `/_profile`, `/_inbox`, `/_search`, `/_create`, `/_edit`,
* `/_version` and `/_error` too, and those have to reach the app shell like any page path. The distinction the
* shell needs is "does something here serve this", which is this list, and it has to be kept in step
* with the registrations below.
*
+10 -1
View File
@@ -1707,7 +1707,7 @@
"common.header.viewSource": "View Source",
"common.license.alr": "All Rights Reserved",
"common.license.cc0": "Public Domain",
"common.license.ccby": " Creative Commons Attribution License",
"common.license.ccby": "Creative Commons Attribution License",
"common.license.ccbync": "Creative Commons Attribution-NonCommercial License",
"common.license.ccbyncnd": "Creative Commons Attribution-NonCommercial-NoDerivs License",
"common.license.ccbyncsa": "Creative Commons Attribution-NonCommercial-ShareAlike License",
@@ -2274,6 +2274,7 @@
"history.action.updated": "Updated",
"history.branchFailed": "Failed to create a page from this version.",
"history.branchOff": "Branch off from here",
"history.branchOffShort": "Branch off",
"history.branchReason": "Branched off from the version of {date}",
"history.branchSuccess": "New page created from this version.",
"history.changedFields": "Changed: {fields}",
@@ -2281,6 +2282,7 @@
"history.downloadFailed": "Failed to download this version.",
"history.downloadVersion": "Download Version",
"history.emptyPage": "Nothing",
"history.exportPdf": "Export to PDF",
"history.inline": "Inline",
"history.loadFailed": "Failed to load the page history.",
"history.none": "No history has been recorded for this page yet.",
@@ -2300,12 +2302,19 @@
"history.setAsSource": "Set as Differencing Source",
"history.setAsTarget": "Set as Differencing Target",
"history.sideBySide": "Side-by-Side",
"history.snapshotFrom": "Snapshot from",
"history.sourceCopied": "Source copied to the clipboard.",
"history.title": "Page History",
"history.unknownAuthor": "Unknown",
"history.versionActions": "Version Actions",
"history.versionId": "Version ID {id}",
"history.versionLoadFailed": "Failed to load this version.",
"history.versionUnavailable": "This version is not available",
"history.versionUnavailableHint": "It may have been purged from the history, or you may not be allowed to see it.",
"history.viewLive": "View Live",
"history.viewSource": "View Source",
"history.viewVersion": "View Version",
"history.viewingVersionId": "Viewing version ID",
"iconPicker.allSets": "All sets",
"iconPicker.icons": "Icons",
"iconPicker.image": "Image",
+94 -46
View File
@@ -1,5 +1,6 @@
import { isEqual } from 'es-toolkit/predicate'
import { and, desc, eq, lt, sql } from 'drizzle-orm'
import type { SQL } from 'drizzle-orm'
import {
pageHistory as pageHistoryTable,
pages as pagesTable,
@@ -41,10 +42,18 @@ export type PurgeTimeframe = keyof typeof purgeTimeframes
* The page fields a version carries beyond the ones with columns of their own.
*
* Taken straight off the stored row, so a field added to a page is captured here without this list
* being touched. The exclusions are either derived from the content (`render`, `toc`, `searchContent`,
* being touched. The exclusions are either derived from the content (`render`, `searchContent`,
* `ts`), fixed for the page's whole life (`id`, `siteId`, `creatorId`, `createdAt`), or bookkeeping
* that says nothing about the version (`hash`, `updatedAt`, `authorId`, `ratingScore`, `ratingCount`,
* `historyData`, `isSearchableComputed`).
*
* `toc` is derived as well and is kept regardless, because the version view draws a contents column
* beside the snapshot and there is nowhere else to get one: a version records the page's SOURCE, and
* the headings only exist in the render, which is not recorded. Deriving them on the way out would
* mean parsing the source a second time to answer what the save had already answered.
*
* It stays in `NOT_REPORTED_AS_CHANGED` below, which is a different question: a contents list moves
* whenever the source does, so naming it among a version's changed fields tells a reader nothing.
*/
const EXCLUDED_FROM_META = new Set([
'id',
@@ -55,7 +64,6 @@ const EXCLUDED_FROM_META = new Set([
'authorId',
'hash',
'render',
'toc',
'searchContent',
'ts',
'ratingScore',
@@ -115,6 +123,58 @@ export type PageHistoryVersion = PageHistoryEntry & {
meta: Record<string, any>
}
/**
* One version row, by whatever identifies it.
*
* Shared by the two ways in — page and version, or version alone — because they differ only in the
* `where`, and a second copy of this projection is a second place for the two to drift apart.
*/
async function selectVersionRow(where: SQL | undefined): Promise<any> {
const rows = await WIKI.db
.select({
id: pageHistoryTable.id,
pageId: pageHistoryTable.pageId,
action: pageHistoryTable.action,
changedFields: pageHistoryTable.changedFields,
reason: pageHistoryTable.reason,
versionDate: pageHistoryTable.versionDate,
path: pageHistoryTable.path,
title: pageHistoryTable.title,
content: pageHistoryTable.content,
meta: pageHistoryTable.meta,
authorId: usersTable.id,
authorName: usersTable.name,
authorEmail: usersTable.email
})
.from(pageHistoryTable)
.leftJoin(usersTable, eq(usersTable.id, pageHistoryTable.authorId))
.where(where)
.limit(1)
return rows[0] ?? null
}
/** That row as a version. `pageId` is deliberately not on it — only one caller wants it. */
function toVersion(row: any): PageHistoryVersion {
return {
id: row.id,
action: row.action,
changedFields: row.changedFields ?? [],
reason: row.reason ?? '',
versionDate: row.versionDate,
path: row.path,
title: row.title,
content: row.content ?? '',
meta: (row.meta ?? {}) as Record<string, any>,
author: {
// -> Null once the account is gone: the version outlives it, see the column's own note
id: row.authorId ?? null,
name: row.authorName ?? '',
email: row.authorEmail ?? ''
}
}
}
/**
* Page history model
*
@@ -248,52 +308,40 @@ class PageHistory {
pageId: string,
versionId: string
): Promise<PageHistoryVersion | null> {
const rows = await WIKI.db
.select({
id: pageHistoryTable.id,
action: pageHistoryTable.action,
changedFields: pageHistoryTable.changedFields,
reason: pageHistoryTable.reason,
versionDate: pageHistoryTable.versionDate,
path: pageHistoryTable.path,
title: pageHistoryTable.title,
content: pageHistoryTable.content,
meta: pageHistoryTable.meta,
authorId: usersTable.id,
authorName: usersTable.name,
authorEmail: usersTable.email
})
.from(pageHistoryTable)
.leftJoin(usersTable, eq(usersTable.id, pageHistoryTable.authorId))
.where(
and(
eq(pageHistoryTable.siteId, siteId),
eq(pageHistoryTable.pageId, pageId),
eq(pageHistoryTable.id, versionId)
)
const row = await selectVersionRow(
and(
eq(pageHistoryTable.siteId, siteId),
eq(pageHistoryTable.pageId, pageId),
eq(pageHistoryTable.id, versionId)
)
.limit(1)
)
return row ? toVersion(row) : null
}
const row: any = rows[0]
if (!row) {
return null
}
return {
id: row.id,
action: row.action,
changedFields: row.changedFields ?? [],
reason: row.reason ?? '',
versionDate: row.versionDate,
path: row.path,
title: row.title,
content: row.content ?? '',
meta: (row.meta ?? {}) as Record<string, any>,
author: {
id: row.authorId ?? null,
name: row.authorName ?? '',
email: row.authorEmail ?? ''
}
}
/**
* The same version addressed by its ID ALONE, with the page it belongs to named in the reply.
*
* What `/_version/<id>` needs. A version URL is a link somebody was handed — out of the history
* timeline, a notification, a message — and the one thing such a link can reasonably carry is the
* version's own ID: the page it came off is exactly what the reader is asking to be told, and a URL
* that already had to name it would be a URL they could not have been given in the first place.
*
* `pageId` comes back because the caller has permissions to check and they are page rules — the
* version carries no access of its own, so it has to be turned back into a page first. Everything
* else is what {@link getVersion} returns.
*
* Scoped to the site regardless, so a version ID from one site cannot be read through another's URL.
*
* @returns The version and its page, or null when this site has no such version
*/
async getVersionById(
siteId: string,
versionId: string
): Promise<(PageHistoryVersion & { pageId: string }) | null> {
const row = await selectVersionRow(
and(eq(pageHistoryTable.siteId, siteId), eq(pageHistoryTable.id, versionId))
)
return row ? { ...toVersion(row), pageId: row.pageId } : null
}
/**