mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 22:01:58 -05:00
feat: view page version by ID
This commit is contained in:
25 files changed
+1820
-485
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user