feat: recycle bin + list folders first option

pull/8104/head
NGPixel 3 days ago
parent f64d643123
commit 41b1e0e9b5
No known key found for this signature in database

@ -117,6 +117,31 @@ export function mayOnPage(req: FastifyRequest, permission: string, page: RulePag
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, page)
}
/**
* The page permissions that open the recycle bin, either one sufficient.
*
* Whoever may delete a page at a path may see what was deleted there and put it back, which is the
* undo of the same act; `manage:pages` is the broader authority over the pages at a path. Nothing
* else in the bin asks for more: viewing, downloading and restoring are one grant, since the bin is
* where a page is recovered FROM and each of the three is a step of doing so.
*/
const RECYCLE_BIN_PERMISSIONS = ['delete:pages', 'manage:pages']
/**
* Whether this requester may see and recover a deleted page, asked of where it was when it went.
*
* The deletion's path, locale and tags stand in for the page, since there is none left to ask about
* -- which is also why this is the only page check that takes a version rather than a page.
*/
function mayRecoverPage(
req: FastifyRequest,
siteId: string,
deleted: { path: string; locale: string; tags?: string[] }
): boolean {
const ref = { siteId, path: deleted.path, locale: deleted.locale, tags: deleted.tags ?? [] }
return RECYCLE_BIN_PERMISSIONS.some((permission) => mayOnPage(req, permission, ref))
}
/**
* Whether this requester may be handed a page's SOURCE.
*
@ -1375,6 +1400,197 @@ async function routes(app: FastifyInstance) {
}
)
/**
* RECYCLE BIN
*/
app.get<{ Params: { siteId: string }; Querystring: { locale: string } }>(
'/sites/:siteId/pages/deleted',
{
/*
No route-level `permissions`: the bin is gated by page rules, `delete:pages` or
`manage:pages`, and those are resolved per deleted page against where it was.
*/
schema: {
summary: 'List deleted pages',
description:
"The site's recycle bin: every page whose newest version is its deletion, most recently deleted first, in one locale.\n\nOnly the pages the caller holds `delete:pages` or `manage:pages` on, where each page was when it was deleted. Each entry names the version recording the deletion, which is what `GET /sites/:siteId/versions/:versionId` reads and `POST /sites/:siteId/pages/:pageId/restore` restores from.",
tags: ['Pages'],
params: siteIdParam,
querystring: {
type: 'object',
properties: {
locale: {
type: 'string',
description: 'The locale the page was in when it was deleted.'
}
},
required: ['locale']
},
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
versionId: {
type: 'string',
format: 'uuid',
description: 'The version recording the deletion.'
},
pageId: { type: 'string', format: 'uuid' },
locale: { type: 'string' },
path: {
type: 'string',
description: 'Where the page was when it was deleted.'
},
title: { type: 'string' },
icon: {
type: 'string',
description: 'An Iconify reference. Empty when the page had none of its own.'
},
editor: { type: 'string' },
deletedAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
},
deletedBy: {
type: 'object',
description:
'Who deleted it. Null id and empty name once that account is deleted.',
properties: {
id: { type: ['string', 'null'], format: 'uuid' },
name: { type: 'string' }
}
}
}
}
}
}
}
},
async (req) => {
const entries = await WIKI.models.pageHistory.listDeleted(req.params.siteId, req.query.locale)
return entries.filter((entry) => mayRecoverPage(req, req.params.siteId, entry))
}
)
app.post<{
Params: { siteId: string; pageId: string }
Body: { versionId: string; render?: string; path?: string; locale?: string; title?: string }
}>(
'/sites/:siteId/pages/:pageId/restore',
{
// -> Checked per page below, for the same reason as the recycle bin listing above
schema: {
summary: 'Restore a deleted page',
description:
"Bring a page back out of the recycle bin under its own id, so its whole history comes back with it. The page is restored as the deletion's snapshot held it; only its render is the caller's, produced from that snapshot's source the way an editor produces one, and sanitized against what the caller may embed.\n\nBack where it was unless `path` / `locale` say otherwise. A path another page has taken since answers 409 `pageDuplicatePath`, and a `versionId` that is no longer the page's deletion (it was restored and deleted again meanwhile) answers 409 `pageRestoreStale`.\n\nNeeds `delete:pages` or `manage:pages` where the page was when it was deleted, and at the destination when that is somewhere else.",
tags: ['Pages'],
params: pageIdParam,
body: {
type: 'object',
properties: {
versionId: {
type: 'string',
format: 'uuid',
description: 'The deletion being undone, as the recycle bin listed it.'
},
render: {
type: 'string',
description: "The HTML produced from the snapshot's source."
},
path: {
type: 'string',
description: 'Where to restore it, when not back where it was.'
},
locale: {
type: 'string',
description: 'The locale to restore it into, when not the one it was in.'
},
title: {
type: 'string',
description: 'A new title, when not the one it had.'
}
},
required: ['versionId']
},
response: {
200: {
description: 'Page restored successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' },
page: { $ref: 'Page#' }
}
}
}
}
},
async (req, reply) => {
const actor = actorFrom(req)
if (!actor) {
return reply.unauthorized('Restoring a page requires a logged in user.')
}
const deletion = await WIKI.models.pageHistory.deletionOf(
req.params.siteId,
req.params.pageId
)
if (
!deletion ||
!mayRecoverPage(req, req.params.siteId, {
path: deletion.path,
locale: deletion.locale,
tags: deletion.meta.tags
})
) {
return reply.notFound('This page is not in the recycle bin.')
}
// -> Somewhere else is somewhere this caller has to be allowed to put it, on the same terms
const destination = {
path: req.body.path ? normalizePagePath(req.body.path) : deletion.path,
locale: req.body.locale || deletion.locale,
tags: deletion.meta.tags
}
if (
(destination.path !== deletion.path || destination.locale !== deletion.locale) &&
!mayRecoverPage(req, req.params.siteId, destination)
) {
return reply.forbidden('You are not allowed to restore a page here.')
}
const { page, versionId } = await WIKI.models.pages.restorePage(
req.params.siteId,
{
pageId: req.params.pageId,
versionId: req.body.versionId,
render: req.body.render,
path: destination.path,
locale: destination.locale,
title: req.body.title
},
actor
)
await audit(req, 'page', 'restorePage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path,
title: page.title,
// -> Both ends: the deletion undone, and the version the page came back as
deletedVersionId: deletion.id,
versionId
})
return {
ok: true,
message: 'Page restored successfully.',
page
}
}
)
/**
* PAGE HISTORY
*/
@ -1483,7 +1699,7 @@ async function routes(app: FastifyInstance) {
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.',
"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.\n\nA version of a page that is in the recycle bin is answered on the recycle bin's terms instead: `delete:pages` or `manage:pages` where the page was when it was deleted, since there is no page left for the rules to be matched against. `pageIsDeleted` is then true, and `pagePath` / `pageLocale` are where it was.",
tags: ['Pages'],
params: {
type: 'object',
@ -1512,6 +1728,34 @@ async function routes(app: FastifyInstance) {
if (!version) {
return reply.notFound('This version does not exist.')
}
/*
A page in the recycle bin has no row for the rules to be matched against, so the deletion
stands in for it: where the page was when it went, which is where the bin decides who may see
it. Every version of it is readable on those terms -- the bin's View opens the deletion, and
the history walked from there is the same page's.
*/
const deletion = await WIKI.models.pageHistory.deletionOf(req.params.siteId, version.pageId)
if (deletion) {
if (
!mayRecoverPage(req, req.params.siteId, {
path: deletion.path,
locale: deletion.locale,
tags: deletion.meta.tags
})
) {
return reply.notFound('This version does not exist.')
}
// -> As for a live page: a password guarded the content, and the bin is not a way around it
if (deletion.meta.password && !mayBypassPassword(req)) {
return reply.forbidden('This page is password protected.')
}
return {
...version,
pageIsDeleted: true,
pagePath: deletion.path,
pageLocale: deletion.locale
}
}
/*
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
@ -1536,7 +1780,7 @@ async function routes(app: FastifyInstance) {
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 }
return { ...version, pageIsDeleted: false, pagePath: page.path, pageLocale: page.locale }
}
)

@ -513,6 +513,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
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.'
},
pageIsDeleted: {
type: 'boolean',
description:
'The page is in the recycle bin. `pagePath` and `pageLocale` are then where it was when it was deleted, and there is no live page to link to.'
},
pagePath: {
type: 'string',
description:

@ -195,6 +195,7 @@
"admin.audit.actions.requestPasswordReset": "Requested a password reset",
"admin.audit.actions.resetPassword": "Reset a password from an emailed link",
"admin.audit.actions.resetUserPassword": "Set a user's password",
"admin.audit.actions.restorePage": "Restored a deleted page",
"admin.audit.actions.retryJob": "Retried a job",
"admin.audit.actions.revokeApiKey": "Revoked an API key",
"admin.audit.actions.runScheduledTask": "Ran a scheduled task",
@ -2584,6 +2585,7 @@
"fileman.aiFileType": "Adobe Illustrator Document",
"fileman.aifFileType": "AIF Audio File",
"fileman.apkFileType": "Android Package",
"fileman.asciidocPageType": "AsciiDoc Page",
"fileman.assetDelete": "Confirm Delete Asset",
"fileman.assetDeleteConfirm": "Are you sure you want to delete {name}?",
"fileman.assetDeleteId": "Asset ID {id}",
@ -2595,7 +2597,11 @@
"fileman.aviFileType": "AVI Video File",
"fileman.binFileType": "Binary File",
"fileman.blogPageType": "Blog",
"fileman.browseUsing": "Browse using...",
"fileman.browseUsingPaths": "Browse Using Paths",
"fileman.browseUsingTitles": "Browse Using Titles",
"fileman.bz2FileType": "BZIP2 Archive",
"fileman.compactList": "Compact List",
"fileman.copyURLSuccess": "URL has been copied to the clipboard.",
"fileman.createFolderInvalidData": "One or more fields are invalid.",
"fileman.createFolderSuccess": "Folder created successfully.",
@ -2605,14 +2611,18 @@
"fileman.detailsAssetDimensions": "Dimensions",
"fileman.detailsAssetSize": "File Size",
"fileman.detailsAssetType": "Type",
"fileman.detailsDeletedAt": "Deleted",
"fileman.detailsDeletedBy": "Deleted By",
"fileman.detailsPageCreated": "Created",
"fileman.detailsPageEditor": "Editor",
"fileman.detailsPageType": "Type",
"fileman.detailsPageUpdated": "Last Updated",
"fileman.detailsPath": "Path",
"fileman.detailsTitle": "Title",
"fileman.dmgFileType": "Apple Disk Image File",
"fileman.docxFileType": "Microsoft Word Document",
"fileman.epsFileType": "EPS Image",
"fileman.excalidrawPageType": "Drawing",
"fileman.exeFileType": "Windows Executable",
"fileman.flacFileType": "FLAC Audio File",
"fileman.folderChildrenCount": "Empty folder | 1 child | {count} children",
@ -2641,6 +2651,7 @@
"fileman.jpegFileType": "JPEG Image",
"fileman.jpgFileType": "JPEG Image",
"fileman.jsonFileType": "JSON Document",
"fileman.listFoldersFirst": "List Folders First",
"fileman.m4aFileType": "M4A Audio File",
"fileman.markdownPageType": "Markdown Page",
"fileman.midFileType": "MIDI Audio File",
@ -2659,11 +2670,21 @@
"fileman.previewFitToScreen": "Fit to Screen",
"fileman.psdFileType": "Adobe Photoshop Document",
"fileman.rarFileType": "RAR Archive",
"fileman.recycleBin": "Recycle Bin",
"fileman.recycleBinEmpty": "The recycle bin is empty.",
"fileman.recycleBinLoadFailed": "Failed to load the recycle bin.",
"fileman.recycleBinLoading": "Fetching deleted pages...",
"fileman.redirectPageType": "Redirection",
"fileman.renameAssetInvalid": "Asset name is invalid.",
"fileman.renameFolderInvalidData": "One or more fields are invalid.",
"fileman.renameFolderSuccess": "Folder renamed successfully.",
"fileman.restoreFailed": "Failed to restore the page.",
"fileman.restorePageTo": "Restore Page To...",
"fileman.restorePathTaken": "Another page now exists at /{path}. Choose where to restore this page instead.",
"fileman.restoreStale": "This page changed in the recycle bin since it was listed. The list has been refreshed.",
"fileman.restoreSuccess": "Page restored successfully.",
"fileman.searchFolder": "Search folder...",
"fileman.showFolders": "Show Folders",
"fileman.svgFileType": "Scalable Vector Graphic",
"fileman.tarFileType": "TAR Archive",
"fileman.tgzFileType": "Gzipped TAR Archive",
@ -2674,6 +2695,7 @@
"fileman.unknownFileType": "{type} file",
"fileman.uploadSuccess": "File(s) uploaded successfully.",
"fileman.viewOptions": "View Options",
"fileman.visualPageType": "Visual Page",
"fileman.wavFileType": "WAV Audio File",
"fileman.wmaFileType": "WMA Audio File",
"fileman.wmvFileType": "WMV Video File",
@ -2690,6 +2712,7 @@
"history.action.created": "Created",
"history.action.deleted": "Deleted",
"history.action.moved": "Moved",
"history.action.restored": "Restored",
"history.action.updated": "Updated",
"history.branchFailed": "Failed to create a page from this version.",
"history.branchOff": "Branch off from here",
@ -2698,6 +2721,7 @@
"history.branchSuccess": "New page created from this version.",
"history.changedFields": "Changed: {fields}",
"history.current": "Current",
"history.deletedPage": "Deleted page",
"history.downloadFailed": "Failed to download this version.",
"history.downloadVersion": "Download Version",
"history.emptyPage": "Nothing",
@ -2713,6 +2737,8 @@
"history.restore.success": "Page version restored succesfully!",
"history.restoreConfirm": "Restore the page content as it was on **{date}**?",
"history.restoreConfirmHint": "The page keeps its current title, tags and settings, and the state it is in now is kept in the history.",
"history.restoreDeletedConfirm": "Restore this page to **/{path}**, as it was when it was deleted on **{date}**?",
"history.restoreDeletedConfirmHint": "Its whole history comes back with it.",
"history.restoreFailed": "Failed to restore this version.",
"history.restoreReason": "Restored the content from {date}",
"history.restoreSuccess": "Page content restored successfully.",

@ -31,6 +31,7 @@ export const AUDIT_ACTIONS = {
'convertPage',
'movePage',
'deletePage',
'restorePage',
'renderPage',
'unlockPage',
'watchPage',

@ -1,5 +1,5 @@
import { isEqual } from 'es-toolkit/predicate'
import { and, desc, eq, lt, sql } from 'drizzle-orm'
import { and, desc, eq, lt, notExists, sql } from 'drizzle-orm'
import type { SQL } from 'drizzle-orm'
import {
pageHistory as pageHistoryTable,
@ -10,11 +10,12 @@ import {
/**
* The kinds of change a history row records.
*
* `created` and `deleted` are the two ends of a page's life; `moved` is a change of path or title,
* which is worth telling apart from an ordinary edit because it is what breaks links; `updated` is
* everything else, content and metadata alike.
* `created` and `deleted` are the two ends of a page's life, and `restored` is a deleted page coming
* back out of the recycle bin under its own id; `moved` is a change of path or title, which is worth
* telling apart from an ordinary edit because it is what breaks links; `updated` is everything else,
* content and metadata alike.
*/
export const pageHistoryActions = ['created', 'updated', 'moved', 'deleted'] as const
export const pageHistoryActions = ['created', 'updated', 'moved', 'deleted', 'restored'] as const
export type PageHistoryAction = (typeof pageHistoryActions)[number]
@ -115,6 +116,36 @@ export type PageHistoryEntry = {
author: PageHistoryAuthor
}
/**
* A page in the recycle bin, as the file manager lists it: the version recording its deletion, and
* enough of what it was to draw a row and to check the page rules it stood under.
*/
export type DeletedPageEntry = {
/** The deletion's own version, which is what viewing, downloading and restoring are built from. */
versionId: string
pageId: string
locale: string
path: string
title: string
icon: string
editor: string
tags: string[]
deletedAt: Date
deletedBy: PageHistoryAuthor
}
/** The version recording a page's deletion, with the snapshot it holds. */
export type PageDeletion = {
id: string
pageId: string
locale: string
path: string
title: string
content: string
meta: Record<string, any>
versionDate: Date
}
/** A version in full, source included. */
export type PageHistoryVersion = PageHistoryEntry & {
content: string
@ -177,8 +208,8 @@ function toVersion(row: any): PageHistoryVersion {
* Page history model
*
* Records a version of a page every time one changes, and reads those versions back for the history
* view — which lists them and diffs any two against each other. Restoring one, and recovering a page
* that was deleted, are still to come.
* view — which lists them and diffs any two against each other — and for the recycle bin, which is
* nothing more than the pages whose last version is their deletion.
*/
class PageHistory {
/**
@ -342,6 +373,172 @@ class PageHistory {
return row ? { ...toVersion(row), pageId: row.pageId } : null
}
/**
* The pages in a site's recycle bin, most recently deleted first.
*
* A page is in the bin when its newest version is a deletion and no page row carries its id. Both
* halves are asked: the newest row alone would be enough today, since nothing can record against a
* page that is gone, but a page restored and deleted again has two deletions, and only the later
* one describes it.
*
* Newest per page across EVERY locale, and filtered by locale only afterwards — a page that was
* deleted in one locale, restored into another and deleted again belongs to the second, and
* filtering first would list it in both.
*
* Every such page, unpaged: the page rules that decide which of these a caller may see are resolved
* per row by the caller, so a page taken here could be a page of rows nobody may see.
*/
async listDeleted(siteId: string, locale: string): Promise<DeletedPageEntry[]> {
const latest = WIKI.db
.selectDistinctOn([pageHistoryTable.pageId], {
id: pageHistoryTable.id,
pageId: pageHistoryTable.pageId,
action: pageHistoryTable.action,
locale: pageHistoryTable.locale,
path: pageHistoryTable.path,
title: pageHistoryTable.title,
icon: sql<string | null>`${pageHistoryTable.meta}->>'icon'`.as('icon'),
editor: sql<string | null>`${pageHistoryTable.meta}->>'editor'`.as('editor'),
tags: sql<string[] | null>`${pageHistoryTable.meta}->'tags'`.as('tags'),
versionDate: pageHistoryTable.versionDate,
authorId: pageHistoryTable.authorId
})
.from(pageHistoryTable)
.where(
and(
eq(pageHistoryTable.siteId, siteId),
notExists(
WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(eq(pagesTable.id, pageHistoryTable.pageId))
)
)
)
.orderBy(
pageHistoryTable.pageId,
desc(pageHistoryTable.versionDate),
desc(pageHistoryTable.id)
)
.as('latest')
const rows = await WIKI.db
.select({
id: latest.id,
pageId: latest.pageId,
locale: latest.locale,
path: latest.path,
title: latest.title,
icon: latest.icon,
editor: latest.editor,
tags: latest.tags,
versionDate: latest.versionDate,
authorId: usersTable.id,
authorName: usersTable.name,
authorEmail: usersTable.email
})
.from(latest)
.leftJoin(usersTable, eq(usersTable.id, latest.authorId))
.where(and(eq(latest.action, 'deleted'), eq(latest.locale, locale)))
.orderBy(desc(latest.versionDate), desc(latest.id))
return rows.map((row: any) => ({
versionId: row.id,
pageId: row.pageId,
locale: row.locale,
path: row.path,
title: row.title,
icon: row.icon ?? '',
editor: row.editor || 'markdown',
tags: Array.isArray(row.tags) ? row.tags : [],
deletedAt: row.versionDate,
deletedBy: {
id: row.authorId ?? null,
name: row.authorName ?? '',
email: row.authorEmail ?? ''
}
}))
}
/**
* The deletion a page is in the recycle bin by, or null when it is not in the bin — because it was
* never deleted, because it has been restored since, or because it never existed on this site.
*
* The newest version of the page, and only when that version is a deletion and no page row carries
* the id. That row is where the page was when it went, which is what its page rules are resolved
* against, and the snapshot a restore puts back.
*/
async deletionOf(siteId: string, pageId: string): Promise<PageDeletion | null> {
const live = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(eq(pagesTable.id, pageId))
.limit(1)
if (live.length > 0) {
return null
}
const rows = await WIKI.db
.select({
id: pageHistoryTable.id,
pageId: pageHistoryTable.pageId,
action: pageHistoryTable.action,
locale: pageHistoryTable.locale,
path: pageHistoryTable.path,
title: pageHistoryTable.title,
content: pageHistoryTable.content,
meta: pageHistoryTable.meta,
versionDate: pageHistoryTable.versionDate
})
.from(pageHistoryTable)
.where(and(eq(pageHistoryTable.siteId, siteId), eq(pageHistoryTable.pageId, pageId)))
.orderBy(desc(pageHistoryTable.versionDate), desc(pageHistoryTable.id))
.limit(1)
const row = rows[0]
if (!row || row.action !== 'deleted') {
return null
}
return {
id: row.id,
pageId: row.pageId,
locale: row.locale,
path: row.path,
title: row.title,
content: row.content ?? '',
meta: (row.meta ?? {}) as Record<string, any>,
versionDate: row.versionDate
}
}
/**
* When a page first appeared and who made it, off its oldest version.
*
* What a restored page takes its `createdAt` and `creatorId` back from: neither is part of a
* version's snapshot (both are fixed for the page's life, see `EXCLUDED_FROM_META`), so without
* this every page brought back would claim to have been written the day it was restored, by
* whoever restored it. Null when the history has been purged past the page's creation.
*/
async originOf(
siteId: string,
pageId: string
): Promise<{ versionDate: Date; authorId: string | null } | null> {
const rows = await WIKI.db
.select({
versionDate: pageHistoryTable.versionDate,
authorId: pageHistoryTable.authorId
})
.from(pageHistoryTable)
.where(
and(
eq(pageHistoryTable.siteId, siteId),
eq(pageHistoryTable.pageId, pageId),
eq(pageHistoryTable.action, 'created')
)
)
.orderBy(pageHistoryTable.versionDate)
.limit(1)
return rows[0] ?? null
}
/**
* Drop every version older than a timeframe, across every site.
*

@ -373,6 +373,24 @@ export interface PageInput {
reasonForChange?: string
}
/** What bringing a page back out of the recycle bin takes. */
export interface PageRestoreInput {
pageId: string
/**
* The deletion being undone, as the caller read it. Asked for rather than looked up alone, because
* the render below was produced from THAT snapshot's source: a page restored and deleted again in
* the meantime has a newer one, and the two must not be mixed.
*/
versionId: string
/** The HTML the browser rendered the snapshot's source into, for where the page is going. */
render?: string
/** Where to put it, when not back where it was. */
path?: string
locale?: string
/** A title of its own, asked for alongside a new path; the one it had otherwise. */
title?: string
}
/** Who is saving, and what they are allowed to put in a page. */
/** One row of the admin dashboard's recently-edited panel. */
export interface RecentPage {
@ -2307,6 +2325,208 @@ class Pages {
return { page, versionId }
}
/**
* Bring a deleted page back, under its own id, from the version that recorded its deletion.
*
* The same id rather than a new page, which is what makes this an undo: every version the page ever
* had is keyed by that id and comes back with it, and so do the `/_version/` links pointing into
* them. The page is restored as the snapshot held it — title, content, tags, config, scripts,
* publish state and password — and put back where it was unless another path is asked for.
*
* What does not come back, and why:
* - The RENDER is not part of a version, so it is the browser's, from the snapshot's source — and
* is sanitized against what the RESTORER may embed, as any render is. Scripts the page carried in
* its own `scripts` column come back regardless, since those are what their author wrote.
* - Its place in a set of TRANSLATIONS. Leaving the set dissolved it if it left one page behind, and
* rejoining one is a statement about the other pages that is not this restore's to make.
* - An ALIAS another page has taken since.
* - Its sidebar MENU, if it overrode one: that was keyed by the page's tree entry and went with it.
* - Its ratings, which are the readers' rows and were dropped with the page.
*
* @throws 404 when the page is not in the bin, 409 when `versionId` is not its current deletion or
* when the destination path is taken
*/
async restorePage(
siteId: string,
input: PageRestoreInput,
actor: PageActor
): Promise<PageChange> {
if (!WIKI.sites[siteId]) {
throw new CustomError('pageInvalidSite', 'This site does not exist.', 404)
}
const deletion = await WIKI.models.pageHistory.deletionOf(siteId, input.pageId)
if (!deletion) {
throw new CustomError('pageNotDeleted', 'This page is not in the recycle bin.', 404)
}
if (deletion.id !== input.versionId) {
throw new CustomError(
'pageRestoreStale',
'This page has changed in the recycle bin since it was loaded. Reload it and try again.',
409
)
}
const meta = deletion.meta
const title = input.title?.trim() || deletion.title
const path = normalizePath(input.path ?? deletion.path)
const locale = input.locale || deletion.locale
const editor = meta.editor || 'markdown'
const contentType = meta.contentType || EDITOR_CONTENT_TYPES[editor] || 'text'
const duplicate = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(
and(eq(pagesTable.siteId, siteId), eq(pagesTable.locale, locale), eq(pagesTable.path, path))
)
.limit(1)
if (duplicate.length > 0) {
throw new CustomError('pageDuplicatePath', 'A page already exists at this path.', 409)
}
const pathParts = path.split('/')
await this.guardAgainstAssetCollision({
siteId,
locale,
parentPath: pathParts.slice(0, -1).join('/'),
fileName: pathParts.at(-1)!,
contentType
})
// -> Kept only while it is still free: an alias is unique across the site, and one taken since is
// somebody else's now
let alias: string | null = meta.alias || null
if (alias) {
const taken = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.alias, alias)))
.limit(1)
if (taken.length > 0) {
alias = null
}
}
/*
Who made it and who owns it, as they were -- but only while those accounts exist. Both columns
are foreign keys, and an account deleted since the page went would otherwise make the page
impossible to restore at all; the restorer stands in, as they would for a page they created.
*/
const origin = await WIKI.models.pageHistory.originOf(siteId, input.pageId)
const wantedUsers = [meta.ownerId, origin?.authorId].filter((id): id is string => Boolean(id))
const existingUsers = new Set(
wantedUsers.length > 0
? (
await WIKI.db
.select({ id: usersTable.id })
.from(usersTable)
.where(inArray(usersTable.id, wantedUsers))
).map((row) => row.id)
: []
)
const creatorId =
origin?.authorId && existingUsers.has(origin.authorId) ? origin.authorId : actor.id
const ownerId = meta.ownerId && existingUsers.has(meta.ownerId) ? meta.ownerId : actor.id
const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
siteId,
input.render ?? '',
{
scripts: hasPermission(actor, 'write:scripts'),
styles: hasPermission(actor, 'write:styles')
}
)
const tags: string[] = Array.isArray(meta.tags) ? meta.tags : []
const inserted = await WIKI.db
.insert(pagesTable)
.values({
id: input.pageId,
alias,
authorId: actor.id,
creatorId,
ownerId,
config: meta.config ?? {},
content: deletion.content,
contentType,
description: meta.description ?? '',
editor,
hash: generatePathHash(path),
icon: meta.icon ?? '',
isBrowsable: meta.isBrowsable ?? true,
isSearchable: meta.isSearchable ?? true,
locale,
localeGroupId: null,
password: meta.password || null,
path,
publishState: meta.publishState ?? 'published',
publishStartDate: meta.publishStartDate ? new Date(meta.publishStartDate) : null,
publishEndDate: meta.publishEndDate ? new Date(meta.publishEndDate) : null,
relations: meta.relations ?? [],
render,
searchContent: text,
scripts: meta.scripts ?? {},
siteId,
tags,
title,
toc,
...(origin ? { createdAt: origin.versionDate } : {})
})
.returning()
const page = inserted[0]
try {
await WIKI.models.tree.addPage({
id: page.id,
parentPath: pathParts.slice(0, -1).join('/'),
fileName: pathParts.at(-1)!,
title: page.title,
locale,
siteId,
tags,
meta: this.treeMeta(page)
})
} catch (err) {
// -> As on a create: a page with no tree entry is invisible to everything that lists the wiki,
// and it is back in the bin rather than lost -- the deletion is still its newest version
await WIKI.db.delete(pagesTable).where(eq(pagesTable.id, page.id))
throw err
}
// -> Outside the rollback, as on a create: the folder is a convenience, see `createPage`
if (editor === BLOG_EDITOR) {
await WIKI.models.blogs.ensureFolder({ siteId, locale, path, title: page.title })
}
await WIKI.models.pageLinks.refreshForPage(page, links)
const versionId = await WIKI.models.pageHistory.record({
siteId,
pageId: page.id,
action: 'restored',
authorId: actor.id
})
const stored = this.toStoragePage(siteId, actor.id, page, page.content ?? '')
await WIKI.models.storage.mirrorPage(stored.ref, stored.content)
await WIKI.models.search.indexPage(page.id, locale)
// -> A create, to anything listening: a page has appeared at a path, which is what a webhook
// subscribed to new pages is there to hear
await WIKI.models.hooks.emit('page:create', {
id: page.id,
path: page.path,
locale,
siteId,
authorId: actor.id,
metadata: { title: page.title, description: page.description, editor }
})
invalidateAppShellCache()
return { page: (await this.getPage({ siteId, id: page.id })) as Page, versionId }
}
/**
* Delete every page on a site carrying a tag.
*

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
282 icons.
283 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@ -121,6 +121,7 @@ export const BUNDLED_ICONS = {
"la:print": {"body":"<path fill=\"currentColor\" d=\"M9 4v7H7c-1.645 0-3 1.355-3 3v10h5v4h14v-4h5V14c0-1.645-1.355-3-3-3h-2V4zm2 2h10v5H11zm-4 7h18c.566 0 1 .434 1 1v8h-3v-4H9v4H6v-8c0-.566.434-1 1-1m1 1c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m3 6h10v6H11z\"/>","width":32,"height":32},
"la:project-diagram": {"body":"<path fill=\"currentColor\" d=\"M3 6v8h5.635L12 19.908V27h8v-8h-6.217l-2.845-5H11v-3h10v3h8V6h-8v3H11V6zm2 2h4v4H5zm18 0h4v4h-4zm-8.582 13H18v4h-4v-3.762z\"/>","width":32,"height":32},
"la:question-circle": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m0 4c-2.2 0-4 1.8-4 4h2c0-1.117.883-2 2-2s2 .883 2 2a1.78 1.78 0 0 1-1.219 1.688l-.406.124A2.02 2.02 0 0 0 15 17.72V19h2v-1.281l.406-.125A3.81 3.81 0 0 0 20 14c0-2.2-1.8-4-4-4m-1 10v2h2v-2z\"/>","width":32,"height":32},
"la:recycle": {"body":"<path fill=\"currentColor\" d=\"M16 3.969a3.27 3.27 0 0 0-2.75 1.5l-2.813 4.625l1.72 1.031l2.812-4.594c.539-.804 1.562-.824 2.094 0l3.125 5l-1.5.875L23.313 15V9.687l-1.438.844L18.75 5.47A3.27 3.27 0 0 0 16 3.97zm-5.594 8.125l-4.5 2.594l1.25.75l-2.562 4.218l-.032-.031c-.011.02.012.043 0 .063C3.09 21.918 4.79 25 7.5 25H13v-2H7.5c-1.266 0-1.945-1.25-1.281-2.219l.031-.031v-.031l2.625-4.25l1.531.937zm14.656 3.562l-1.718 1.063l2.5 4c.64 1.09-.086 2.281-1.25 2.281H19v-2l-4.906 3L19 27v-2h5.594c2.636 0 4.328-3 2.968-5.313v-.03z\"/>","width":32,"height":32},
"la:redo": {"body":"<path fill=\"currentColor\" d=\"M19.219 5.281L17.78 6.72L24.063 13H11c-3.844 0-7 3.156-7 7v7h2v-7c0-2.754 2.246-5 5-5h13.063l-6.282 6.281l1.438 1.438l8-8l.687-.719l-.687-.719z\"/>","width":32,"height":32},
"la:redo-alt": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13h-2c0 6.086-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5c3.875 0 7.262 1.984 9.219 5H20v2h8V4h-2v3.719C23.617 4.844 20.02 3 16 3\"/>","width":32,"height":32},
"la:reply": {"body":"<path fill=\"currentColor\" d=\"m12.281 5.281l-8 8l-.687.719l.687.719l8 8l1.438-1.438L7.438 15H21c2.773 0 5 2.227 5 5s-2.227 5-5 5v2c3.855 0 7-3.145 7-7s-3.145-7-7-7H7.437l6.282-6.281z\"/>","width":32,"height":32},

File diff suppressed because it is too large Load Diff

@ -345,7 +345,8 @@ const ACTION_STYLES = {
created: { icon: 'la:plus', dot: 'bg-positive' },
updated: { icon: 'la:pen', dot: 'bg-blue-7' },
moved: { icon: 'la:share', dot: 'bg-warning' },
deleted: { icon: 'la:trash', dot: 'bg-negative' }
deleted: { icon: 'la:trash', dot: 'bg-negative' },
restored: { icon: 'la:undo', dot: 'bg-teal' }
}
const ACTION_FALLBACK = { icon: 'la:circle', dot: 'bg-grey-7' }

@ -107,6 +107,7 @@
@click="emit(`branch`)" />
<!-- -> The same orange every page header gives the action that changes the page -->
<w-btn
v-if="canRestore"
class="acrylic-btn ml-2"
flat
icon="la:undo"
@ -134,10 +135,8 @@
primary as TEXT on a dark page, but as a FILL behind white it measures 2.4:1 where plain
`primary` gives 4.6:1. A fill carries its own contrast, so one colour serves both themes.
Rendered only with a path to go to. In practice there is always one, since the endpoint behind
this view refuses a version whose page has been deleted (page rules need a page to be checked
against), but a button whose target is empty would navigate to the site root and quietly look
like it had worked.
Rendered only with a path to go to, which a page in the recycle bin does not have: a button
whose target is empty would navigate to the site root and quietly look like it had worked.
-->
<w-btn
class="ml-2"
@ -185,6 +184,11 @@ defineProps({
livePath: {
type: String,
default: ''
},
/** Whether Restore is offered -- see `canRestore` in `PageVersion.vue`. */
canRestore: {
type: Boolean,
default: true
}
})

@ -293,6 +293,10 @@ const header = computed(() => {
case 'renamePage': {
return { icon: 'img:/_assets/icons/fluent-rename.svg', title: 'pageRenameDialog.title' }
}
// -> Out of the recycle bin, to somewhere other than where it was: that path has been taken
case 'restorePage': {
return { icon: 'img:/_assets/icons/fluent-save-as.svg', title: 'fileman.restorePageTo' }
}
case 'renameAsset': {
return { icon: 'img:/_assets/icons/fluent-rename.svg', title: 'fileman.assetRenameMove' }
}
@ -691,7 +695,8 @@ onMounted(async () => {
state.typesToFetch = ['folder', 'page']
break
}
case 'renamePage': {
case 'renamePage':
case 'restorePage': {
state.typesToFetch = ['folder', 'page']
state.pathDirty = true
break

@ -0,0 +1,176 @@
import { defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import { confirm, dialog } from '@/composables/dialog'
import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { renderVersionSource } from '@/helpers/pageVersions'
import { useEditorStore } from '@/stores/editor'
import { useSiteStore } from '@/stores/site'
/**
* Bringing a page back out of the recycle bin.
*
* Shared by the two places that offer it -- the file manager's Recycle Bin, and the version view a
* bin entry opens in -- because the flow is the same from both and has more to it than a request: the
* page's HTML has to be rendered here first (a version records the source and never the render), and
* a path that has been taken since the page went has to become a question about where else to put it.
*
* Call it during `setup()`, like any composable: it reads the i18n catalogue and the stores.
*
* @returns {{ restoreDeletedPage: (version: object) => Promise<object | null> }}
*/
export function useDeletedPages() {
const { t } = useI18n()
const editorStore = useEditorStore()
const siteStore = useSiteStore()
async function renderFor(version, pagePath) {
// -> Configured per site, and the configuration arrives with the editor configs
if (!editorStore.configIsLoaded) {
await editorStore.fetchConfigs()
}
return renderVersionSource(version, {
markdownConfig: editorStore.editors.markdown,
asciidocConfig: editorStore.editors.asciidoc,
pagePath
})
}
function deletedOn(version) {
return version.versionDate
? Temporal.Instant.from(version.versionDate).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
: ''
}
/**
* Ask where the page should go instead, in the same tree picker a duplicate or a move uses.
*
* @returns {Promise<{ path: string, title: string, locale: string } | null>} Null when dismissed.
*/
function pickDestination(version, path, locale) {
return new Promise((resolve) => {
dialog({
component: defineAsyncComponent(() => import('@/components/TreeBrowserDialog.vue')),
componentProps: {
mode: 'restorePage',
itemId: version.pageId,
itemTitle: version.title,
itemFileName: path,
locale
}
})
.onOk(resolve)
.onCancel(() => resolve(null))
})
}
/**
* One attempt at the request, somewhere in particular.
*
* @returns {Promise<{ page?: object, conflict?: string }>} `conflict` is the server's error name for
* the two refusals the caller has an answer to; anything else is thrown.
*/
async function attempt(version, { path, locale, title }) {
loading.show()
try {
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages/${version.pageId}/restore`, {
json: {
versionId: version.id,
// -> Rendered for where it is going: a relative link or image resolves against that path
render: await renderFor(version, path),
path,
locale,
...(title ? { title } : {})
}
}).json()
// -> The API client does not throw on 400, so a refused path comes back as a parsed error
if (resp?.ok === false || !resp?.page?.id) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
return { page: resp.page }
} catch (err) {
if (['pageDuplicatePath', 'pageRestoreStale'].includes(err?.data?.error)) {
return { conflict: err.data.error }
}
throw err
} finally {
loading.hide()
}
}
/**
* Restore a deleted page: confirm, then put it back where it was -- or, when another page has taken
* that path since, wherever the reader picks instead.
*
* @param {object} version The version recording the deletion, WITH its `content`, as
* `GET /sites/:siteId/versions/:versionId` answers for a page in the bin: `pagePath` and
* `pageLocale` are where it was.
* @returns {Promise<object | null | 'stale'>} The restored page; null when the reader backed out or
* it failed (already reported); `stale` when the bin changed underneath, which the caller answers
* by reloading whatever it listed.
*/
function restoreDeletedPage(version) {
return new Promise((resolve) => {
confirm({
title: t('history.restore'),
message: [
t('history.restoreDeletedConfirm', {
path: version.pagePath,
date: deletedOn(version)
}),
t('history.restoreDeletedConfirmHint')
],
cancel: true,
okLabel: t('history.restore')
})
.onOk(async () => {
let target = { path: version.pagePath, locale: version.pageLocale }
try {
for (;;) {
const result = await attempt(version, target)
if (result.page) {
notify({ type: 'positive', message: t('fileman.restoreSuccess') })
resolve(result.page)
return
}
if (result.conflict === 'pageRestoreStale') {
notify({ type: 'warning', message: t('fileman.restoreStale') })
resolve('stale')
return
}
// -> The path is taken. Asked again for as long as the answer is a taken path too
notify({
type: 'warning',
message: t('fileman.restorePathTaken', { path: target.path })
})
const picked = await pickDestination(version, target.path, target.locale)
if (!picked) {
resolve(null)
return
}
target = picked
}
} catch (err) {
notify({
type: 'negative',
message: t('fileman.restoreFailed'),
caption: apiErrorMessage(err)
})
resolve(null)
}
})
.onCancel(() => resolve(null))
})
}
return { restoreDeletedPage }
}

@ -23,6 +23,13 @@
<!-- -> Monospaced and selectable: it is an identifier, and the reason to show one in full is
so it can be read off and quoted -->
<span class="text-caption font-robotomono select-all truncate">{{ state.version.id }}</span>
<!-- -> Said outright, since everything else on this screen looks the same either way: the
page this is a version of is in the recycle bin, and there is no live page behind it -->
<span
v-if="state.version.pageIsDeleted"
class="text-caption shrink-0 ml-2 px-2 rounded bg-negative text-white">
{{ t('history.deletedPage') }}
</span>
</div>
<!--
Off on a phone, as the page view's date is: on a 390px screen it takes a whole line of its own
@ -40,6 +47,7 @@
:title="state.version.title"
:description="versionDescription"
:live-path="livePath"
:can-restore="canRestore"
@download="downloadVersion"
@restore="restoreVersion"
@branch="branchFrom" />
@ -152,6 +160,7 @@ import { computed, defineAsyncComponent, nextTick, reactive, ref, watch } from '
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDeletedPages } from '@/composables/deletedPages'
import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen'
import { confirm, dialog } from '@/composables/dialog'
@ -184,6 +193,10 @@ const route = useRoute()
const { t } = useI18n()
// COMPOSABLES
const { restoreDeletedPage } = useDeletedPages()
// DATA
const state = reactive({
@ -252,12 +265,23 @@ const tocDepth = computed(() => state.version?.meta?.config?.tocDepth ?? { min:
*/
const livePath = computed(() => {
const path = state.version?.pagePath
if (!path) {
// -> A page in the recycle bin has no live page: `pagePath` is then where it WAS
if (!path || state.version.pageIsDeleted) {
return ''
}
return `${siteStore.localeUrlPrefix(state.version.pageLocale)}/${path}`
})
/**
* Whether Restore is offered. Always for a live page, where it puts this version's source back on it.
* For a page in the recycle bin, only on the version recording its deletion: restoring brings the
* page back from that snapshot, so offering it on an older version would restore something other
* than what is on screen.
*/
const canRestore = computed(
() => !state.version?.pageIsDeleted || state.version.action === 'deleted'
)
/*
Whether there is a contents section to draw, rather than whether the page asked for one: a version
with no headings, or whose depth settings leave nothing to list, would otherwise show "Contents" over
@ -396,11 +420,21 @@ async function downloadVersion() {
* text. Nothing is lost either way -- this is an ordinary edit, so it becomes a version of its own
* with the current state recorded in it.
*/
function restoreVersion() {
async function restoreVersion() {
const version = state.version
if (!version) {
return
}
// -> Out of the recycle bin rather than onto a live page: there is no page to PATCH
if (version.pageIsDeleted) {
const restored = await restoreDeletedPage(version)
if (restored === 'stale') {
await loadVersion(version.id)
} else if (restored) {
router.push(`${siteStore.localeUrlPrefix(restored.locale)}/${restored.path}`)
}
return
}
confirm({
title: t('history.restore'),
message: [

Loading…
Cancel
Save