diff --git a/backend/api/pages.ts b/backend/api/pages.ts index b1b6e4182..2809d5707 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -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/` 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/` 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 } } ) diff --git a/backend/api/schemas/page.ts b/backend/api/schemas/page.ts index 2298fa1ae..4989a5f84 100644 --- a/backend/api/schemas/page.ts +++ b/backend/api/schemas/page.ts @@ -513,6 +513,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { 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: diff --git a/backend/locales/en.json b/backend/locales/en.json index c27380cc9..d367bafbe 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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.", diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 7489a139b..09be17200 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -31,6 +31,7 @@ export const AUDIT_ACTIONS = { 'convertPage', 'movePage', 'deletePage', + 'restorePage', 'renderPage', 'unlockPage', 'watchPage', diff --git a/backend/models/pageHistory.ts b/backend/models/pageHistory.ts index 31f5fa885..d0bebae34 100644 --- a/backend/models/pageHistory.ts +++ b/backend/models/pageHistory.ts @@ -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 + 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 { + 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`${pageHistoryTable.meta}->>'icon'`.as('icon'), + editor: sql`${pageHistoryTable.meta}->>'editor'`.as('editor'), + tags: sql`${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 { + 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, + 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. * diff --git a/backend/models/pages.ts b/backend/models/pages.ts index 15c1965f3..aa5903b10 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -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 { + 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. * diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index c4f74f512..ed7a214f1 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -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":"","width":32,"height":32}, @@ -121,6 +121,7 @@ export const BUNDLED_ICONS = { "la:print": {"body":"","width":32,"height":32}, "la:project-diagram": {"body":"","width":32,"height":32}, "la:question-circle": {"body":"","width":32,"height":32}, + "la:recycle": {"body":"","width":32,"height":32}, "la:redo": {"body":"","width":32,"height":32}, "la:redo-alt": {"body":"","width":32,"height":32}, "la:reply": {"body":"","width":32,"height":32}, diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index ed0a81671..ee0c411d9 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -101,29 +101,68 @@ Narrower while it overlays, so there is a comfortable width of scrim left to tap on. --> - +
+ + + +
+ +
+
-
- -
- + +
-