feat: view page backlinks

This commit is contained in:
NGPixel committed 2026-09-15 20:09:12 -04:00
1 parent bf889ecfb2
commit 0385c375c9
25 files changed
+8651 -42

No files matched your search

+192
View File
@@ -1614,6 +1614,198 @@ async function routes(app: FastifyInstance) {
}
)
/**
* LIST BACKLINKS
*
* What links to this page.
*
* Asked by the page's ADDRESS rather than by its id, which is the whole reason the table stores an
* address: a link says where it points, so the pages pointing at one that has moved still point at
* where it was. The two short links are folded in by id and by alias, since those two survive a move
* by design and belong in the same list.
*/
app.get<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/backlinks',
{
/*
No route-level `permissions`: reading a page's backlinks is `read:pages` ON THAT PAGE, and on
every page in the answer — see below.
*/
schema: {
summary: 'List the pages linking to a page',
description:
"Every page of this instance whose content, redirection target or sidebar relations point at this one, with the href each of them wrote.\n\nAnswers 404 where the site has `features.backlinks` off, which is the switch under General → Features. A page that has turned its own Links tab off with `allowBacklinks` still answers here — that flag hides the tab, and what it hides is not a secret.\n\nFiltered by what the caller may read, one page at a time: a backlink names a page, its title and where it sits, so listing one the caller has no `read:pages` rule for would hand them the existence of a page they cannot open. A link written on another SITE of this instance is included, since a move here breaks it just the same.\n\nNote that links pointing at a page's OLD path are not listed here — after a move they address a path this page no longer has, which is what makes them findable as the links that move broke.",
tags: ['Pages'],
params: pageIdParam,
response: {
200: {
description: 'Pages linking here',
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
siteId: { type: 'string', format: 'uuid' },
locale: { type: 'string' },
path: { type: 'string' },
title: { type: 'string' },
description: { type: 'string' },
icon: {
type: 'string',
description:
"The page's own icon as an Iconify reference, or empty where it has none."
},
url: {
type: 'string',
description: 'Where that page is, as a path on its own site.'
},
hostname: {
type: 'string',
nullable: true,
description:
'The host its site answers on, for a page on another site of this instance. Null when it is on the site being read, or on the catch-all site.'
},
href: {
type: 'string',
description: 'The link as that page writes it, which is what a repair would edit.'
},
kind: {
type: 'string',
description: 'How the link addresses this page: by path, by alias or by id.'
}
}
}
}
}
}
},
async (req, reply) => {
// -> The site-wide switch, as `requireBuiltInPage` checks the comments one: with the feature
// off there is nothing here to answer, however the page itself is set
if (!WIKI.models.pageLinks.isAllowed(req.params.siteId)) {
return reply.notFound('This site does not show what links to a page.')
}
const page = await loadReadablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')
}
// -> As the history route does: a page withheld until its password is entered withholds what is
// derived from it too, and its link graph names it in one direction and reads its content in
// the other
if (page.isLocked) {
return reply.forbidden('This page is password protected.')
}
const backlinks = await WIKI.models.pageLinks.backlinksFor(
{ siteId: req.params.siteId, locale: page.locale, path: page.path },
{ pageId: page.id, alias: page.alias }
)
return backlinks
.filter((link) =>
mayOnPage(req, 'read:pages', {
siteId: link.siteId,
locale: link.locale,
path: link.path,
tags: link.tags
})
)
.map((link) => ({
id: link.pageId,
siteId: link.siteId,
locale: link.locale,
path: link.path,
title: link.title,
description: link.description ?? '',
icon: link.icon ?? '',
url: link.url,
hostname: link.hostname,
href: link.href,
kind: link.kind
}))
}
)
/**
* LIST OUTBOUND LINKS
*
* What this page links to, and whether anything is there.
*
* The red-link question. Resolution is a join rather than a stored flag, so a page created at a path
* somebody had already linked to makes that link good without anything having to notice.
*/
app.get<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/links',
{
// -> No route-level `permissions`: `read:pages` on the page itself, as above
schema: {
summary: 'List the links written on a page',
description:
"Every link this page's content, redirection target or sidebar relations point at, each with the href as written and what it resolves to. `exists` is false for a link addressing a page that is not there — the state a wiki draws as a red link.\n\nLinks leaving the wiki are not recorded and are not listed. Uploaded files are listed but never resolved: they are tracked so a moved file can be traced back to what pointed at it, and answering whether one exists is a different lookup.",
tags: ['Pages'],
params: pageIdParam,
response: {
200: {
description: 'Links written on this page',
type: 'array',
items: {
type: 'object',
properties: {
href: { type: 'string' },
kind: { type: 'string' },
targetSiteId: { type: 'string', format: 'uuid' },
targetLocale: { type: 'string', nullable: true },
targetPath: { type: 'string', nullable: true },
targetId: { type: 'string', format: 'uuid', nullable: true },
targetTitle: { type: 'string', nullable: true },
exists: { type: 'boolean' }
}
}
}
}
}
},
async (req, reply) => {
const page = await loadReadablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')
}
// -> These come out of the page's own body, which is the half a password withholds
if (page.isLocked) {
return reply.forbidden('This page is password protected.')
}
const links = await WIKI.models.pageLinks.outboundFor(page.id)
return links.map((link) => ({
href: link.href,
kind: link.kind,
targetSiteId: link.targetSiteId,
targetLocale: link.targetLocale,
targetPath: link.targetPath,
targetId: link.targetPageId,
/*
Only what the caller may read, for the reason the backlinks route filters: whether a page
exists at a path is something a rule decides they may know. A link to a page they may not
read reads as a link to nothing, which is also what clicking it would give them.
Judged on where the RESOLVED page sits rather than on what the link said about it — an alias
or an id link names no address at all — and with its tags, since a rule may be written
against those.
*/
...(link.targetPageId &&
!mayOnPage(req, 'read:pages', {
siteId: link.targetSiteId,
locale: link.targetPageLocale ?? undefined,
path: link.targetPagePath ?? '',
tags: link.targetPageTags ?? []
})
? { targetTitle: null, exists: false }
: { targetTitle: link.targetTitle, exists: Boolean(link.targetPageId) })
}))
}
)
/**
* PAGE USER PERMISSIONS
*/
+2
View File
@@ -119,6 +119,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'string'
}
},
allowBacklinks: { type: 'boolean' },
allowComments: { type: 'boolean' },
allowContributions: { type: 'boolean' },
allowRatings: { type: 'boolean' },
@@ -217,6 +218,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
description:
'Only present when the request asked for it — except on a redirection, whose content is where it sends its reader rather than a body, and comes back either way.'
},
allowBacklinks: { type: 'boolean' },
allowComments: { type: 'boolean' },
allowContributions: { type: 'boolean' },
allowRatings: { type: 'boolean' },
+5
View File
@@ -80,6 +80,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
features: {
type: 'object',
properties: {
backlinks: {
type: 'boolean',
description:
'Whether a page shows what links to it. Off hides the Links tab across the site and closes the route behind it; a page can still opt out on its own with `allowBacklinks`. Nothing about how links are recorded changes either way — the table is written by every save regardless, so turning this back on shows a list rather than an empty tab.'
},
browse: {
type: 'boolean'
},
+58
View File
@@ -1439,6 +1439,64 @@ async function routes(app: FastifyInstance) {
}
)
/**
* REBUILD PAGE LINKS
*
* What every page points at, worked out again from the render each one already stores.
*
* Not a repair anybody should need after an ordinary edit: a page's links are rewritten every time
* its render is. It is for the two cases where there was nothing to derive them from at the time —
* content that predates the table, and a site whose locale prefixes or page extensions changed,
* which changes what a link already written in a page addresses without touching the page.
*/
app.post(
'/page-links/rebuild',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Rebuild the page link index',
description:
'Queues a job that re-reads the stored render of every page on every site and records what each one links to. Runs in a worker thread because it touches all the content in the instance, so the response only says the job was queued.',
tags: ['System'],
response: {
200: {
description: 'Rebuild queued successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid',
description: 'ID of the queued job, which the scheduler view lists.'
}
}
}
}
}
},
async (req, reply) => {
const added = await WIKI.scheduler.addJob({ task: 'rebuildPageLinks' })
if (!added?.id) {
return reply.internalServerError('The scheduler could not queue the rebuild.')
}
await audit(req, 'admin', 'rebuildPageLinks', { jobId: added.id })
return {
ok: true,
message: 'Page link rebuild queued successfully.',
id: added.id
}
}
)
/**
* CHECK FOR UPDATE
*/
@@ -0,0 +1,22 @@
CREATE TABLE "pageLinks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"kind" varchar(16) NOT NULL,
"href" varchar(2048) NOT NULL,
"targetSiteId" uuid NOT NULL,
"targetLocale" varchar(255),
"targetPath" varchar(255),
"targetRef" varchar(255),
"createdAt" timestamp DEFAULT now() NOT NULL,
"pageId" uuid NOT NULL,
"siteId" uuid NOT NULL
);
--> statement-breakpoint
CREATE INDEX "pageLinks_target_idx" ON "pageLinks" ("targetSiteId","targetLocale","targetPath");--> statement-breakpoint
CREATE INDEX "pageLinks_targetRef_idx" ON "pageLinks" ("targetSiteId","kind","targetRef");--> statement-breakpoint
CREATE INDEX "pageLinks_pageId_idx" ON "pageLinks" ("pageId");--> statement-breakpoint
CREATE INDEX "pageLinks_siteId_idx" ON "pageLinks" ("siteId");--> statement-breakpoint
CREATE UNIQUE INDEX "pageLinks_pageId_href_idx" ON "pageLinks" ("pageId","href");--> statement-breakpoint
CREATE UNIQUE INDEX "pages_siteId_locale_path_idx" ON "pages" ("siteId","locale","path");--> statement-breakpoint
ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_targetSiteId_sites_id_fkey" FOREIGN KEY ("targetSiteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");
File diff suppressed because it is too large. Load diff
+98 -1
View File
@@ -556,7 +556,15 @@ export const pages = pgTable(
index('pages_isSearchableComputed_idx').on(table.isSearchableComputed),
// -> One page per locale in a group, enforced here rather than in the model: a group is edited
// from any of its members, so two saves racing each other are two writers of the same set
uniqueIndex('pages_localeGroupId_locale_idx').on(table.localeGroupId, table.locale)
uniqueIndex('pages_localeGroupId_locale_idx').on(table.localeGroupId, table.locale),
/*
Where a page sits, which is what a path addresses it by. Unique because two pages at one path in
one locale is the thing `createPage` and `movePage` both check for and neither can actually
prevent -- their check and their write are two statements, so two saves racing each other both
see a clear path. It is also the index the link table resolves against: "is there a page at this
address" is asked once per link on a page.
*/
uniqueIndex('pages_siteId_locale_path_idx').on(table.siteId, table.locale, table.path)
]
)
@@ -634,6 +642,95 @@ export const pageHistory = pgTable(
]
)
// PAGE LINKS --------------------------
/**
* One row per distinct link written on a page, resolved to what it addresses.
*
* Derived from the stored render the way `toc` and `searchContent` are, and rewritten wholesale
* whenever that render is — see `models/pageLinks.ts`. Three questions are asked of it: what links to
* the page being read, whether what a page links to exists, and which pages have to be revisited when
* something they point at moves.
*
* **The address is what is stored, not a resolved page id.** A link is written as a path, and after a
* move the pages pointing at the old one still say the old one — which is precisely the thing worth
* knowing, and precisely what a foreign key would erase by following the page. It also lets a link to
* a page that does not exist yet be a row like any other, which is what a red link is. Whether a
* target resolves is a join against `pages` on `(siteId, locale, path)` at read time.
*
* `/a/<alias>` and `/i/<id>` are the exception and are stored as the reference they carry: those two
* survive a move by design, so resolving them to a path at write time would record the opposite of
* what they mean.
*/
export const pageLinks = pgTable(
'pageLinks',
{
id: uuid().primaryKey().defaultRandom(),
/**
* What the link addresses: a page by path (`page`), by alias (`alias`) or by id (`pageId`), or an
* uploaded file (`asset`).
*
* A varchar rather than an enum, for the reason `pageHistory.action` is one — naming another kind
* of target later should not need a migration. Links leaving the wiki are not recorded at all:
* nothing asks a question about them until there is a link checker to answer it, and they would
* be the bulk of the rows on a wiki that cites its sources.
*/
kind: varchar({ length: 16 }).notNull(),
/**
* The href exactly as it appears in the page.
*
* Kept because resolving is lossy and the source is what a repair would have to edit: `../two`,
* `/en/one/two` and `/one/two.md` are one target and three strings, and only the string that was
* written can be found in the markdown again.
*
* Bounded rather than `text` because it is half of a unique btree index below, and a btree entry
* has a hard ceiling of about 2700 bytes — an href past it would fail the INSERT, and that INSERT
* is part of saving a page. 2048 is the conventional cap for a URL and far past anything a link
* in a wiki page is; `resolveLink` drops the ones that would not fit rather than truncating them
* into a different address.
*/
href: varchar({ length: 2048 }).notNull(),
/**
* Which site the target belongs to. Usually the source's own, and another one for a link written
* as an absolute URL to a second site of this instance — those are worth following rather than
* writing off as external, since a move on either site breaks them just the same.
*/
targetSiteId: uuid()
.notNull()
.references(() => sites.id),
// -> Both null for `alias` and `pageId`, which address a page without saying where it is
targetLocale: varchar({ length: 255 }),
targetPath: varchar({ length: 255 }),
/** The alias or the page id, for the two kinds that carry one. Null for the rest. */
targetRef: varchar({ length: 255 }),
createdAt: timestamp().notNull().defaultNow(),
// -> The page the link is written on. Its rows go with it: a link is part of a page's content,
// and nothing is left to point at once the page is gone
pageId: uuid()
.notNull()
.references(() => pages.id, { onDelete: 'cascade' }),
siteId: uuid()
.notNull()
.references(() => sites.id)
},
(table) => [
// -> "What links here", and "what points at this path" for a page about to be moved. Leading with
// the site because every one of those questions is asked within one
index('pageLinks_target_idx').on(table.targetSiteId, table.targetLocale, table.targetPath),
// -> The same question for the two kinds that address a page without a path
index('pageLinks_targetRef_idx').on(table.targetSiteId, table.kind, table.targetRef),
// -> Every link on one page, which is both the read for "do these targets exist" and the delete
// half of rewriting a page's links
index('pageLinks_pageId_idx').on(table.pageId),
index('pageLinks_siteId_idx').on(table.siteId),
/*
One row per spelling, not per target: a page linking to the same place as `../two` and as
`/one/two` has two links to repair and two rows saying so. Two identical hrefs on one page are
one row, since there is nothing to tell them apart and nothing that would ask.
*/
uniqueIndex('pageLinks_pageId_href_idx').on(table.pageId, table.href)
]
)
// PAGE EDIT SUBMISSIONS ---------------
/**
* An edit suggested by somebody who may read a page but not change it, waiting to be reviewed.
+272
View File
@@ -0,0 +1,272 @@
import * as cheerio from 'cheerio'
import { isPageUrl, normalizePagePath, splitLocalePath, stripPageExtension } from './common.ts'
/**
* Reading a link the way the reader's browser will.
*
* A page stores the HTML its editor produced, and that HTML carries the href the author wrote:
* `fileSrc` rewrites image sources and deliberately leaves links alone, because a relative link means
* exactly what it says. So `../two`, `/en/one/two`, `/one/two.md` and
* `https://wiki.example.com/en/one/two` can all be the same page, and something has to say so before
* any question about links can be answered.
*
* That makes this the THIRD copy of the rules for reading a page URL, after `helpers/common.ts` —
* which it composes rather than reimplements — and `frontend/src/helpers/pagePaths.js`. It has to
* agree with both: a link this reads differently from the router is a backlink pointing somewhere the
* reader does not land.
*/
/**
* Every href in a render, exactly as written and with repeats left in.
*
* A page's links are read from its stored HTML rather than from its source, because there is no
* markdown parser on this side at all: the render is produced in the editor's browser and sent up,
* and it is the one settled form of a page the server holds. It is also what makes rebuilding a
* wiki's links cost a parse per page instead of a headless browser per page.
*
* @param $ A document already loaded, for the caller that has one — `postProcess` is holding the very
* tree this would otherwise re-parse.
*/
export function hrefsFrom($: cheerio.CheerioAPI): string[] {
const hrefs: string[] = []
for (const el of $('a[href]')) {
const href = $(el).attr('href')
if (href) {
hrefs.push(href)
}
}
return hrefs
}
/** The same, for a stored render with nothing loaded — which is every page but the one being saved. */
export function linksFromRender(html?: string | null): string[] {
if (!html) {
return []
}
return hrefsFrom(cheerio.load(html, null, false))
}
/** What a link addresses. See the `kind` column. */
export type PageLinkKind = 'page' | 'alias' | 'pageId' | 'asset'
export interface ResolvedLink {
kind: PageLinkKind
/** The href as written, which is what a repair has to find in the source again. */
href: string
/** Which site the target is on — the source's own, unless the href named another one. */
targetSiteId: string
/** Where the target sits, for the two kinds that say. Null for `alias` and `pageId`. */
targetLocale: string | null
targetPath: string | null
/** The alias or the page id, for the two kinds that carry one. */
targetRef: string | null
}
/** The page a link is written on, which is what a relative href resolves against. */
export interface LinkSource {
siteId: string
locale: string
path: string
}
/**
* The origin every href is resolved against.
*
* Nothing here cares what the host is, only whether two of them are the same, and a render is
* processed outside any request — so there is no real origin to reach for. A placeholder that cannot
* collide with a hostname anybody has configured is what makes `new URL()` usable on a relative href,
* and comparing what comes out against this is how "did this link stay on this site" is asked.
*/
const LOCAL_ORIGIN = 'https://page-links.invalid'
/** Where uploaded files are served from, and the one non-page path worth recording. */
const FILES_PREFIX = '/_files/'
/** The short links to a page that survive a move, and the segment each is addressed by. */
const ALIAS_PREFIX = '/a/'
const PAGE_ID_PREFIX = '/i/'
/**
* The longest href that gets a row, matching the column it is stored in.
*
* Not a judgement about what a link may be — it is what a unique btree index can hold, and the insert
* is part of saving a page. Past it the link is not recorded, which costs a row in a derived table
* and never the save.
*/
const MAX_HREF_LENGTH = 2048
/**
* Which site an href ended up on, or null when it left the instance.
*
* A link written as an absolute URL to another site of this wiki is followed rather than written off:
* `WIKI.sitesMappings` is the same lookup the request hooks do, so a second site's hostname resolves
* to that site and a move there breaks the link just as visibly.
*
* **The catch-all mapping is deliberately not consulted.** `*` answers for every hostname that
* reaches this server, so honouring it here would make every external link a page link — every URL a
* page cites would resolve to a path on the site that happens to be bound to `*`. The cost is the
* other half of that: on a site with no hostname of its own, a link somebody wrote by pasting the
* address out of their browser is not recognised as internal, because there is nothing configured to
* recognise it against. Links written as paths — which is every link the link picker produces — never
* reach this at all, and are unaffected.
*/
function siteForHost(hostname: string, sourceSiteId: string): string | null {
if (hostname === new URL(LOCAL_ORIGIN).hostname) {
return sourceSiteId
}
return WIKI.sitesMappings?.[hostname] ?? null
}
/**
* Read one href as the address of something in this wiki, or null when it is not one.
*
* Null covers rather a lot, and all of it on purpose: an empty href, a bare fragment, a `mailto:` or
* `tel:`, a link to another server, and every path the wiki serves for itself — `/_admin`, `/login`,
* `/_api` — none of which is content this can be asked a question about. Links leaving the wiki are
* not recorded at all; see the `kind` column.
*
* @param source The page the link is written on. A relative href resolves against the URL that page
* is served at, locale prefix and all, which is what the browser following it does.
*/
export function resolveLink(href: string, source: LinkSource): ResolvedLink | null {
const raw = (href ?? '').trim()
if (raw.length < 1 || raw.length > MAX_HREF_LENGTH || raw.startsWith('#')) {
return null
}
/*
A scheme that is not http(s) is not a page under any reading -- `mailto:`, `tel:`, `data:`, and
the `javascript:` a sanitised render should not be carrying anyway. Tested before `new URL()`
rather than after, since those parse perfectly well and would otherwise have to be excluded by
protocol afterwards.
*/
if (/^[a-z][a-z\d+.-]*:/i.test(raw) && !/^https?:/i.test(raw)) {
return null
}
let url: URL
try {
// -> The page's own address as the base, so `../two` from `one/deep/three` lands where a reader
// clicking it lands. `urlFor` is what puts the site's locale prefix on it, if it uses one
url = new URL(
raw,
`${LOCAL_ORIGIN}${WIKI.models.pages.urlFor(source.siteId, source.locale, source.path)}`
)
} catch {
return null
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null
}
const targetSiteId = siteForHost(url.hostname, source.siteId)
if (!targetSiteId) {
return null
}
// -> The query and the fragment address a part of the target, never a different one, so what is
// left is the whole of where the link goes
const urlPath = url.pathname
if (urlPath.startsWith(ALIAS_PREFIX)) {
const alias = decodeURIComponent(urlPath.slice(ALIAS_PREFIX.length)).replace(/\/+$/, '')
return alias.length > 0
? {
kind: 'alias',
href: raw,
targetSiteId,
targetLocale: null,
targetPath: null,
targetRef: alias
}
: null
}
if (urlPath.startsWith(PAGE_ID_PREFIX)) {
const id = decodeURIComponent(urlPath.slice(PAGE_ID_PREFIX.length)).replace(/\/+$/, '')
return id.length > 0
? {
kind: 'pageId',
href: raw,
targetSiteId,
targetLocale: null,
targetPath: null,
targetRef: id
}
: null
}
if (urlPath.startsWith(FILES_PREFIX)) {
return resolveFile(raw, urlPath, targetSiteId)
}
if (!isPageUrl(urlPath)) {
return null
}
return resolvePage(raw, urlPath, targetSiteId)
}
/**
* A page address, as the SEO hook and the router between them read one.
*
* Same order as `index.ts`: the extension comes off first, since `/en/one/two.md` carries both, and
* the locale prefix second. A path arriving without a prefix is the site's primary locale, which is
* where the server redirects it and what the app loads for it.
*/
function resolvePage(href: string, urlPath: string, targetSiteId: string): ResolvedLink | null {
const site = WIKI.sites?.[targetSiteId]
const trimmed = urlPath.length > 1 && urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath
const withoutExtension = stripPageExtension(trimmed, site?.config?.pageExtensions) ?? trimmed
const locales = site?.config?.locales
const split = splitLocalePath(
withoutExtension,
WIKI.models.locales.urlPrefixesFor(locales?.active)
)
return {
kind: 'page',
href,
targetSiteId,
// -> A path with no prefix names the primary locale, which is where the SEO hook redirects it
// and what the app loads for it. Same fallback as `pages.defaultLocale`
targetLocale: split?.locale ?? locales?.primary ?? 'en',
// -> The site root is the page stored at the empty path, which is what `normalizePagePath` makes
// of `/` -- and of `/fr` once its prefix has come off above
targetPath: normalizePagePath(split?.path ?? withoutExtension),
targetRef: null
}
}
/**
* An uploaded file, addressed the way `controllers/files.ts` serves one.
*
* `/_files/<folders…>/<name.ext>`, and no locale segment: a file URL names a path and nothing else,
* and `getAssetByPath` picks the site's primary locale among the translations filed under it. So
* unlike a page link there is no locale to record — the path IS the address.
*
* Lowercased whole, which is how the lookup reads it: the tree stores a file name in lower case and
* `resolveAssetPath` keys its cache on the path in lower case, so two spellings of one file must not
* become two targets here.
*
* Recorded for the same reason a page link is: a file moves when the folder holding it is renamed,
* and what pointed at it is the thing worth knowing.
*/
function resolveFile(href: string, urlPath: string, targetSiteId: string): ResolvedLink | null {
const rest = decodeURIComponent(urlPath.slice(FILES_PREFIX.length))
.split('/')
.filter(Boolean)
.join('/')
.toLowerCase()
if (rest.length < 1) {
return null
}
return {
kind: 'asset',
href,
targetSiteId,
targetLocale: null,
targetPath: rest,
targetRef: null
}
}
+17
View File
@@ -180,6 +180,7 @@
"admin.audit.actions.purgeEmptyFolders": "Deleted the empty folders",
"admin.audit.actions.purgePageHistory": "Purged page history",
"admin.audit.actions.purgeSampleContent": "Purged the sample content",
"admin.audit.actions.rebuildPageLinks": "Rebuilt the page link index",
"admin.audit.actions.rebuildSearchIndex": "Rebuilt the search index",
"admin.audit.actions.refreshIconSets": "Refreshed the icon sets",
"admin.audit.actions.regenerateCertificates": "Regenerated the API key certificates",
@@ -464,6 +465,8 @@
"admin.flags.title": "Flags",
"admin.flags.warn.hint": "Doing so may result in data loss, performance issues or a broken installation!",
"admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!",
"admin.general.allowBacklinks": "Allow Backlinks",
"admin.general.allowBacklinksHint": "Give every page a Links tab listing the pages that point at it. A page can still hide its own from its properties, and links keep being recorded either way.",
"admin.general.allowBrowse": "Allow Browsing",
"admin.general.allowBrowseHint": "Can users browse using the tree structure of the site to pages they have access to?",
"admin.general.allowCollaborativeEditing": "Allow Collaborative Editing",
@@ -1445,6 +1448,12 @@
"admin.utilities.purgeSampleFailed": "The sample content could not be deleted.",
"admin.utilities.purgeSampleHint": "Delete every page on this site tagged \"test\", which is what Generate Sample Content tags the pages it writes.",
"admin.utilities.purgeSampleSuccess": "There was no sample content to delete. | Deleted 1 page. | Deleted {count} pages.",
"admin.utilities.rebuildPageLinks": "Rebuild Page Links",
"admin.utilities.rebuildPageLinksConfirm": "Every page on every site will be read again to work out what it links to.",
"admin.utilities.rebuildPageLinksConfirmWarn": "Nothing about your content is changed — only the record of which page points at which. This normally keeps itself up to date, so it is worth running after importing content written elsewhere, or after changing a site's locale prefixes or page extensions.",
"admin.utilities.rebuildPageLinksFailed": "Failed to queue a page link rebuild.",
"admin.utilities.rebuildPageLinksHint": "Work out again what every page links to, from the content already stored. Runs in the background.",
"admin.utilities.rebuildPageLinksSuccess": "A page link rebuild has been initiated and will start shortly.",
"admin.utilities.scanPageProblems": "Scan for Page Problems",
"admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.",
"admin.utilities.subtitle": "Maintenance and miscellaneous tools",
@@ -1793,6 +1802,12 @@
"common.license.ccbynd": "Creative Commons Attribution-NoDerivs License",
"common.license.ccbysa": "Creative Commons Attribution-ShareAlike License",
"common.license.none": "None",
"common.links.count": "1 page links here | {count} pages link here",
"common.links.loadFailed": "Could not load the pages linking here.",
"common.links.loading": "Loading links…",
"common.links.none": "No page links here yet.",
"common.links.noneHint": "Pages that link to this one will be listed here.",
"common.links.title": "Pages linking here",
"common.loading": "Loading...",
"common.modernBrowser": "modern browser",
"common.newpage.create": "Create Page",
@@ -1833,6 +1848,7 @@
"common.page.suggestSubmitted": "Your suggested edits have been submitted.",
"common.page.suggestSubmittedHint": "They are pending review. You will be able to keep editing them until a reviewer accepts or declines them.",
"common.page.suggestSubmittedHintGuest": "They are pending review by this site's editors.",
"common.page.tabLinks": "Links",
"common.page.tags": "Tags",
"common.page.tagsMatching": "Pages matching tags",
"common.page.toc": "Table of Contents",
@@ -2108,6 +2124,7 @@
"editor.pendingAssetsNotInSuggestions": "Images and files cannot be attached to a suggested edit.",
"editor.pendingAssetsUploading": "Uploading assets...",
"editor.props.alias": "Alias",
"editor.props.allowBacklinks": "Show Links Tab",
"editor.props.allowComments": "Allow Comments",
"editor.props.allowCommentsHint": "Enable commenting abilities on this page.",
"editor.props.allowContributions": "Allow Contributions",
+1
View File
@@ -117,6 +117,7 @@ export const AUDIT_ACTIONS = {
'updateSecurity',
'updateSearchConfig',
'rebuildSearchIndex',
'rebuildPageLinks',
'installExtension',
'updateApiState',
'updateMetricsState',
+2
View File
@@ -17,6 +17,7 @@ import { mail } from './mail.ts'
import { metrics } from './metrics.ts'
import { navigation } from './navigation.ts'
import { pageHistory } from './pageHistory.ts'
import { pageLinks } from './pageLinks.ts'
import { pages } from './pages.ts'
import { pageWatching } from './pageWatching.ts'
import { passkeys } from './passkeys.ts'
@@ -52,6 +53,7 @@ export default {
metrics,
navigation,
pageHistory,
pageLinks,
pages,
pageWatching,
passkeys,
+416
View File
@@ -0,0 +1,416 @@
import { and, eq, ne, or, sql } from 'drizzle-orm'
import { pageLinks as pageLinksTable, pages as pagesTable } from '../db/schema.ts'
import { linksFromRender, resolveLink } from '../helpers/pageLinks.ts'
import type { LinkSource, PageLinkKind, ResolvedLink } from '../helpers/pageLinks.ts'
/**
* Page links model
*
* What every page points at, kept in step with the page.
*
* The rows are derived, exactly as `toc` and `searchContent` are, and from the same thing: the stored
* render. There is no markdown parser on this side — a page's HTML is produced in the editor's
* browser and sent up — so the render is the only settled form of a page the server has, and
* `rendering.linksFromRender` is what reads the anchors out of it. Two link sources are not in the
* render at all and are collected by the pages model instead: a redirection's target lives in its
* content, and a page's relations are stored as their own column.
*
* Nothing here decides what a link MEANS — `helpers/pageLinks.ts` does that, and is where the rules
* for reading a page URL live. This model is the storage half: swap a page's rows, and answer the
* three questions the table exists for.
*/
/**
* The editor whose content IS a link.
*
* Spelled out here rather than imported from `models/pages.ts`, which pulls in the storage, search and
* tree models behind it — this model is one of the few a worker thread loads, and the rebuild utility
* runs in one. See `worker.ts` on what an import costs there.
*/
const REDIRECT_EDITOR = 'redirect'
/** The columns of a page that say what it links to. */
export interface PageLinkSource {
id: string
siteId: string
locale: string
path: string
editor: string
content: string | null
relations: unknown
render: string | null
}
/** A link on a page, with the page it was found on. */
export interface PageLinkRow {
id: string
pageId: string
kind: PageLinkKind
href: string
targetSiteId: string
targetLocale: string | null
targetPath: string | null
targetRef: string | null
}
/** One page linking to another, as a "what links here" list shows it. */
export interface Backlink {
pageId: string
siteId: string
locale: string
path: string
title: string
description: string | null
/** The page's own icon, as an Iconify reference. Empty where it has none. */
icon: string | null
/** How the link is written, since the same page may point at a target more than one way. */
href: string
kind: PageLinkKind
tags: string[]
publishState: string
/** Where that page is, as a path on ITS OWN site — locale prefix included only where that site
brackets its URLs by one. */
url: string
/**
* The host that site answers on, so a reader looking at one site can still follow a link back to a
* page on another. Null for the catch-all site, which has no host of its own to name.
*/
hostname: string | null
}
/** A link on a page, and whether anything is actually at the other end. */
export interface OutboundLink extends PageLinkRow {
/** The page it resolves to, or null when nothing is there — a red link. */
targetPageId: string | null
targetTitle: string | null
/*
Where the resolved page actually sits, and what it is tagged with, rather than what the link said
about it. Both are here so the caller can ask whether this reader may know the page exists: an
alias or an id link carries no address at all, and a page rule can be written against tags.
*/
targetPageLocale: string | null
targetPagePath: string | null
targetPageTags: string[] | null
}
/** Where a page sits, which is how a link addresses it. */
export interface LinkTarget {
siteId: string
locale: string
path: string
}
class PageLinks {
/**
* Whether this site shows what links to a page.
*
* **Display only.** Off, the Links tab is not drawn and the route behind it answers 404 — and every
* save still records what it links to, exactly as before. That is the whole point of putting the
* switch here rather than around `refreshForPage`: a site that turns this off for a year and back
* on again has a complete answer waiting, where one that stopped writing the table would have a
* year of pages to rebuild before the tab said anything true.
*
* `!== false` rather than a truth test, so a site whose config blob has never been written with the
* key reads as on — which is what the default in `sites.createSite` says it is.
*/
isAllowed(siteId: string | undefined): boolean {
return siteId ? WIKI.sites[siteId]?.config?.features?.backlinks !== false : false
}
/**
* Work out everything a page points at, and store it.
*
* Three sources, because a link is not only a thing in an article:
*
* - **The render**, which is where an author's own links are, and where nearly all of them are.
* - **A redirection's target**, which is not in a render at all — a redirect page has no body, it
* has a destination, and it is the strongest link in the wiki: a reader following one never sees
* that it broke, they just land nowhere.
* - **The page's relations**, the sidebar links its properties dialog collects. Stored as their own
* column, written by the same link picker that writes one into content, and just as breakable.
*
* @param renderHrefs What `postProcess` already read out of the render it just produced, for a save
* that is storing one. Absent, the stored render is parsed instead — which is the
* case for a page that MOVED: nothing about it changed, but every relative link
* on it now resolves somewhere else.
*/
async refreshForPage(page: PageLinkSource, renderHrefs?: string[]): Promise<void> {
const hrefs = renderHrefs ?? linksFromRender(page.render)
if (page.editor === REDIRECT_EDITOR && page.content) {
try {
const redirect = JSON.parse(page.content)
// -> `kind: 'url'` is a destination off this wiki, which `resolveLink` would discard anyway.
// Passed through regardless, so that the one place deciding what is a link stays the one
// place deciding it
if (typeof redirect?.target === 'string') {
hrefs.push(redirect.target)
}
} catch {
// -> A redirection whose content will not parse points nowhere, which is no links rather than
// a failed save. `normalizeRedirectContent` is what stops one being written in the first
// place
}
}
for (const relation of (Array.isArray(page.relations) ? page.relations : []) as any[]) {
if (typeof relation?.target === 'string') {
hrefs.push(relation.target)
}
}
await this.replaceFor(
{ pageId: page.id, siteId: page.siteId, locale: page.locale, path: page.path },
hrefs
)
}
/**
* The same, for a caller holding an id rather than a row.
*
* What everything but a create reaches for: a save, a move, a folder rename and the rebuild utility
* all need the page as it stands AFTER their write, so reading it back is the point rather than an
* overhead.
*/
async refreshById(siteId: string, pageId: string, renderHrefs?: string[]): Promise<void> {
const rows = await WIKI.db
.select({
id: pagesTable.id,
siteId: pagesTable.siteId,
locale: pagesTable.locale,
path: pagesTable.path,
editor: pagesTable.editor,
content: pagesTable.content,
relations: pagesTable.relations,
render: pagesTable.render
})
.from(pagesTable)
.where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId)))
.limit(1)
// -> Gone while the save that asked for this was in flight. Its rows went with it
if (rows[0]) {
await this.refreshForPage(rows[0] as PageLinkSource, renderHrefs)
}
}
/**
* Replace every link recorded for a page.
*
* Wholesale rather than differential: a save rewrites the page, and working out which links
* survived it costs more than the handful of rows it would save. In one transaction so that a page
* is never momentarily linkless — the backlinks of everything it points at would flicker.
*
* Resolution happens here rather than at extraction time because it needs the page's own address:
* `../two` means a different page depending on where the page holding it sits, which is also why
* this has to run again when a page MOVES even though nothing about its content changed.
*
* Unresolvable hrefs are dropped silently — an external link, a `mailto:`, a bare fragment, an
* admin path. See `resolveLink` for the whole list; none of them is a link this can be asked a
* question about.
*
* @param source The page the links were found on
* @param hrefs Every href the page carries, in any order and with repeats
*/
async replaceFor(source: LinkSource & { pageId: string }, hrefs: string[]): Promise<void> {
const resolved = new Map<string, ResolvedLink>()
for (const href of hrefs) {
const link = resolveLink(href, source)
if (link) {
// -> One row per spelling, so the same href written twice on a page is one link. Keyed on the
// href as written, which is what the unique index holds
resolved.set(link.href, link)
}
}
await WIKI.db.transaction(async (trx: any) => {
await trx.delete(pageLinksTable).where(eq(pageLinksTable.pageId, source.pageId))
if (resolved.size < 1) {
return
}
await trx.insert(pageLinksTable).values(
[...resolved.values()].map((link) => ({
pageId: source.pageId,
siteId: source.siteId,
kind: link.kind,
href: link.href,
targetSiteId: link.targetSiteId,
targetLocale: link.targetLocale,
targetPath: link.targetPath,
targetRef: link.targetRef
}))
)
})
}
/** Forget everything a page pointed at, for a page that is going. */
async removeFor(pageId: string): Promise<void> {
await WIKI.db.delete(pageLinksTable).where(eq(pageLinksTable.pageId, pageId))
}
/**
* Every page linking to a target, as "what links here" asks it.
*
* Addressed by locale and path rather than by page id, and deliberately: a link says where it
* points, so the pages pointing at a page that has moved are still pointing at where it WAS. Asking
* by address is what lets a move ask about the path it is leaving, which is the only time that
* question has an answer.
*
* The two id-addressed kinds are folded in by page id, since those survive a move by design and
* would otherwise be missing from the one list anybody reads.
*
* Nothing here filters by what the caller may read: a backlink names a page, and which of them this
* reader is allowed to know about is a page rule the route resolves — see `api/pages.ts`.
*/
async backlinksFor(
target: LinkTarget,
{ pageId, alias }: { pageId?: string; alias?: string | null } = {}
): Promise<Backlink[]> {
const addressedByPath = and(
eq(pageLinksTable.kind, 'page'),
eq(pageLinksTable.targetLocale, target.locale),
eq(pageLinksTable.targetPath, target.path)
)
const addressedById = pageId
? and(eq(pageLinksTable.kind, 'pageId'), eq(pageLinksTable.targetRef, pageId))
: undefined
const addressedByAlias = alias
? and(eq(pageLinksTable.kind, 'alias'), eq(pageLinksTable.targetRef, alias))
: undefined
const rows = await WIKI.db
.select({
pageId: pageLinksTable.pageId,
href: pageLinksTable.href,
kind: pageLinksTable.kind,
siteId: pagesTable.siteId,
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
description: pagesTable.description,
icon: pagesTable.icon,
tags: pagesTable.tags,
publishState: pagesTable.publishState
})
.from(pageLinksTable)
.innerJoin(pagesTable, eq(pagesTable.id, pageLinksTable.pageId))
.where(
and(
eq(pageLinksTable.targetSiteId, target.siteId),
// -> A page linking to itself is not a backlink, it is a page mentioning where it already is
...(pageId ? [ne(pageLinksTable.pageId, pageId)] : []),
or(addressedByPath, addressedById, addressedByAlias)
)
)
.orderBy(pagesTable.locale, pagesTable.path)
return rows.map((row: any) => ({
...row,
tags: row.tags ?? [],
// -> A backlink may be written on another site of this instance, and a bare path would resolve
// against the host the reader is already on. Same pair as `getRecentlyEdited`, for the same
// reason: `*` is the catch-all rather than a host, and a link to it is wherever you are
url: WIKI.models.pages.urlFor(row.siteId, row.locale, row.path),
hostname:
WIKI.sites[row.siteId]?.hostname && WIKI.sites[row.siteId].hostname !== '*'
? WIKI.sites[row.siteId].hostname
: null
})) as Backlink[]
}
/**
* Every link written on a page, with whether the far end exists.
*
* The join is what answers the red-link question, and it is a join rather than a stored flag for
* the reason the whole table is addressed by path: a page created at a path somebody had already
* linked to makes that link good, without anything having to notice.
*
* Assets are left unresolved here — they are recorded so that a moved file can be traced back to
* what pointed at it, and answering whether one exists means a tree lookup per link.
*/
async outboundFor(pageId: string): Promise<OutboundLink[]> {
const rows = await WIKI.db
.select({
id: pageLinksTable.id,
pageId: pageLinksTable.pageId,
kind: pageLinksTable.kind,
href: pageLinksTable.href,
targetSiteId: pageLinksTable.targetSiteId,
targetLocale: pageLinksTable.targetLocale,
targetPath: pageLinksTable.targetPath,
targetRef: pageLinksTable.targetRef,
targetPageId: pagesTable.id,
targetTitle: pagesTable.title,
targetPageLocale: pagesTable.locale,
targetPagePath: pagesTable.path,
targetPageTags: pagesTable.tags
})
.from(pageLinksTable)
.leftJoin(
pagesTable,
or(
and(
eq(pageLinksTable.kind, 'page'),
eq(pagesTable.siteId, pageLinksTable.targetSiteId),
eq(pagesTable.locale, pageLinksTable.targetLocale),
eq(pagesTable.path, pageLinksTable.targetPath)
),
and(
eq(pageLinksTable.kind, 'pageId'),
sql`${pagesTable.id}::text = ${pageLinksTable.targetRef}`
),
and(
eq(pageLinksTable.kind, 'alias'),
eq(pagesTable.siteId, pageLinksTable.targetSiteId),
eq(pagesTable.alias, pageLinksTable.targetRef)
)
)
)
.where(eq(pageLinksTable.pageId, pageId))
.orderBy(pageLinksTable.href)
return rows as OutboundLink[]
}
/**
* The pages that will be left pointing at nothing if these targets move or go.
*
* The bulk form of `backlinksFor`, for a folder rename — which moves every page under it at once —
* and for anything else that has to warn about a change before making it. One query for the lot,
* since a rename can move hundreds of pages and asking per page would be hundreds of round trips.
*
* Only path-addressed links: the id and alias kinds survive a move, so they are not affected by
* one and do not belong in a list of what it breaks.
*
* @returns The id of every page holding such a link, without repeats
*/
async dependentsOf(
siteId: string,
targets: { locale: string; path: string }[]
): Promise<string[]> {
if (targets.length < 1) {
return []
}
const rows = await WIKI.db
.selectDistinct({ pageId: pageLinksTable.pageId })
.from(pageLinksTable)
.where(
and(
eq(pageLinksTable.targetSiteId, siteId),
eq(pageLinksTable.kind, 'page'),
or(
...targets.map((target) =>
and(
eq(pageLinksTable.targetLocale, target.locale),
eq(pageLinksTable.targetPath, target.path)
)
)
)
)
)
return rows.map((row: any) => row.pageId as string)
}
}
export const pageLinks = new PageLinks()
+50 -6
View File
@@ -134,6 +134,7 @@ const reAlias = /^[a-zA-Z0-9-_]*$/
/** Fields kept in the `config` blob rather than as columns, and flattened again on the way out. */
const CONFIG_FIELDS = [
'allowBacklinks',
'allowComments',
'allowContributions',
'allowRatings',
@@ -207,6 +208,8 @@ export interface Page {
password?: string | null
/** Whether the body was withheld because the page is password protected. See `getPage`. */
isLocked: boolean
/** Whether this page shows what links to it. The site-wide switch is `features.backlinks`. */
allowBacklinks: boolean
relations: any[]
/**
* This page in the other locales, as far as the requester may see them. Empty for a page that is
@@ -257,6 +260,7 @@ export interface PageInput {
isBrowsable?: boolean
isSearchable?: boolean
password?: string
allowBacklinks?: boolean
relations?: any[]
/**
* The page's counterparts in other locales, stating the whole set rather than adding to it: a locale
@@ -525,6 +529,12 @@ class Pages {
...((withContent || row.editor === REDIRECT_EDITOR) && !locked
? { content: row.content ?? '' }
: {}),
/*
Absent means yes, as every one of these does: the key is only written once somebody has
edited the page's properties, and a page nobody has touched shows what links to it. The
site-wide switch is `features.backlinks` -- see `pageLinks.isAllowed`.
*/
allowBacklinks: config.allowBacklinks ?? true,
allowComments: config.allowComments ?? true,
allowContributions: config.allowContributions ?? true,
allowRatings: config.allowRatings ?? true,
@@ -1436,7 +1446,7 @@ class Pages {
})
const alias = await this.validateAlias(siteId, input.alias)
const { render, toc, text } = await WIKI.models.rendering.postProcess(
const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
siteId,
input.render ?? '',
{
@@ -1518,6 +1528,10 @@ class Pages {
throw err
}
// -> What this page points at, which is only knowable once it has an id and an address of its
// own: a relative link resolves against the page holding it
await WIKI.models.pageLinks.refreshForPage(page, links)
const versionId = await WIKI.models.pageHistory.record({
siteId,
pageId: page.id,
@@ -1646,14 +1660,20 @@ class Pages {
}
// -> A render only means anything next to the content it came from, so the two move together
let renderHrefs: string[] | undefined
if (patch.render !== undefined) {
const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, patch.render, {
scripts: hasPermission(actor, 'write:scripts'),
styles: hasPermission(actor, 'write:styles')
})
const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
siteId,
patch.render,
{
scripts: hasPermission(actor, 'write:scripts'),
styles: hasPermission(actor, 'write:styles')
}
)
values.render = render
values.toc = toc
values.searchContent = text
renderHrefs = links
}
if (CONFIG_FIELDS.some((field) => patch[field] !== undefined)) {
@@ -1678,6 +1698,14 @@ class Pages {
const updated = (await this.getPage({ siteId, id })) as Page
/*
Unconditionally, and not only for a save that carried a render: a redirection's destination is
its content, and the sidebar relations are a column of their own, so a save touching either of
those changes what the page links to without the render moving at all. Read back rather than
assembled from the patch, for the same reason `changedFields` is worked out against the row.
*/
await WIKI.models.pageLinks.refreshById(siteId, id, renderHrefs)
const versionId = await WIKI.models.pageHistory.record({
siteId,
pageId: id,
@@ -1906,6 +1934,14 @@ class Pages {
*/
await WIKI.models.search.indexPage(id, newLocale)
/*
Not the pages pointing AT this one -- those still say where it was, which is the thing worth
knowing and exactly what the table is for. This is the other direction: every relative link the
moved page itself carries resolved against the folder it used to sit in, and now resolves
against a different one. Nothing about its content changed and every one of its links may have.
*/
await WIKI.models.pageLinks.refreshById(siteId, id)
// -> Moved and then rewritten, rather than deleted and written afresh: the move is what keeps a
// versioned target's history of the file attached to it, and the rewrite is because a move may
// carry a new title and always carries a new modification time, both of which are in the copy
@@ -2432,7 +2468,11 @@ class Pages {
html: string,
permissions: RenderPermissions
): Promise<void> {
const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, html, permissions)
const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
siteId,
html,
permissions
)
const updated = await WIKI.db
.update(pagesTable)
@@ -2443,6 +2483,9 @@ class Pages {
// -> Nothing was updated when the page went while it sat in the queue
if (updated[0]) {
await WIKI.models.search.indexPage(id, updated[0].locale)
// -> An imported page is saved with no render at all and gets one from here, so this is where
// its links first appear
await WIKI.models.pageLinks.refreshById(siteId, id, links)
// -> This is the column the injected copy of a page IS, so a re-render changes it
invalidateAppShellCache()
}
@@ -2547,6 +2590,7 @@ class Pages {
): Record<string, any> {
const defaults = WIKI.sites[siteId]?.config?.defaults ?? {}
return {
allowBacklinks: input.allowBacklinks ?? existing.allowBacklinks ?? true,
allowComments: input.allowComments ?? existing.allowComments ?? true,
allowContributions: input.allowContributions ?? existing.allowContributions ?? true,
allowRatings: input.allowRatings ?? existing.allowRatings ?? true,
+14 -1
View File
@@ -4,6 +4,7 @@ import { eq, inArray, sql } from 'drizzle-orm'
import { flipFromString, rotateFromString } from '@iconify/utils'
import { jobs as jobsTable, pageRenderQueue as renderQueueTable } from '../db/schema.ts'
import { CustomError } from '../helpers/common.ts'
import { hrefsFrom } from '../helpers/pageLinks.ts'
import type { IconifyIcon } from '@iconify/types'
import type { IconifyIconCustomisations } from '@iconify/utils'
@@ -64,6 +65,15 @@ export interface PostProcessResult {
toc: TocNode[]
/** Plain text, for the search index. */
text: string
/**
* Every href the page carries, exactly as written and with repeats left in.
*
* Raw on purpose: what an href ADDRESSES depends on where the page holding it sits, on the site's
* locale prefixes and on its page extensions, none of which is rendering's business. See
* `helpers/pageLinks.ts`, which reads them, and `models/pageLinks.ts`, which stores what it makes
* of them.
*/
links: string[]
}
/**
@@ -376,7 +386,10 @@ class Rendering {
return {
render: $.html(),
toc,
text: this.extractText($)
text: this.extractText($),
// -> Read off the tree that is already loaded rather than by re-parsing the output, which is
// what `linksFromRender` has to do for a page nobody is saving
links: hrefsFrom($)
}
}
+6
View File
@@ -126,6 +126,11 @@ class Sites {
}
},
features: {
// -> On, because what a wiki is for is pages that point at each other, and what points at
// the page in front of you is worth knowing by default. It gates the TAB and the route
// behind it; links are recorded either way, so turning it off and on again shows the
// same list rather than an empty one.
backlinks: true,
browse: true,
collaborativeEditing: true,
ratings: false,
@@ -421,6 +426,7 @@ class Sites {
}
},
features: {
backlinks: true,
browse: true,
collaborativeEditing: true,
ratings: false,
+11
View File
@@ -1001,6 +1001,13 @@ class Tree {
// -> A folder rename never crosses locales, so the page's own is where it came from too
{ locale: page.locale, path: page.previousPath }
)
/*
And the links each page WRITES, which is the other thing a path carries: a relative link
resolved against the folder these pages used to sit in, and resolves against the renamed one
now. The links pointing at them are deliberately left alone -- they say where the pages were,
which is what makes them findable as the ones this rename has broken.
*/
await WIKI.models.pageLinks.refreshById(folder.siteId, page.id)
}
// -> A storage target that lays its content out by path has every one of those files to move.
@@ -1205,6 +1212,7 @@ class Tree {
password: page.password ?? '',
relations: page.relations,
tags: page.tags,
allowBacklinks: page.allowBacklinks,
allowComments: page.allowComments,
allowContributions: page.allowContributions,
allowRatings: page.allowRatings,
@@ -1470,6 +1478,9 @@ class Tree {
},
{ locale: folder.locale, path: page.previousPath }
)
// -> As in `renameFolder`: every relative link these pages carry resolved against where they
// were. This move can cross locales as well, which changes what a link's prefix means too
await WIKI.models.pageLinks.refreshById(siteId, page.id)
}
// -> A storage target that lays its content out by path has every one of those files to move.
+106
View File
@@ -0,0 +1,106 @@
import NodeCache from 'node-cache'
import { asc, gt } from 'drizzle-orm'
import { pages as pagesTable } from '../../db/schema.ts'
import { locales } from '../../models/locales.ts'
import { pageLinks } from '../../models/pageLinks.ts'
import { pages } from '../../models/pages.ts'
import { settings } from '../../models/settings.ts'
import { sites } from '../../models/sites.ts'
/**
* Work out what every page on the wiki links to, from scratch.
*
* The links of a page are derived from its stored render and rewritten whenever that render is, so
* ordinary editing keeps them current on its own. This is for the cases where there was nothing to
* derive them from at the time: pages that predate the table, and a wiki whose locale prefixes or
* page extensions changed, which silently changes what a link that was already written ADDRESSES.
*
* Offered under Admin → Utilities and never run on its own. It is not a migration and not a boot step:
* a wiki with no links recorded works, it simply has no backlinks to show yet, and a rebuild that ran
* itself on every start would re-read every render in the wiki to produce what is usually the same
* rows.
*
* **In a worker thread**, which is what this file being here means — `addJob` sends a task with no
* in-process implementation to the pool. A wiki's pages are its whole content, and parsing every
* render in the instance is minutes of CPU on a large one: on the main thread that is the event loop
* not serving pages for the duration.
*
* The price is that a worker starts with nothing but config and a logger (see `worker.ts`), and
* resolving a link needs rather more than that — which is what `prepare` below is for.
*/
/** How many pages are read at once. A render is a whole page of HTML, so this is a memory ceiling. */
const BATCH_SIZE = 50
/**
* Put back the parts of the `WIKI` global that reading a link needs.
*
* A worker thread is deliberately bare, and what an href means is decided against a good deal of
* instance state: which hostname belongs to which site, what a site's locale prefixes and page
* extensions are, and what short code each locale answers to. All of it is loaded here exactly as
* `postBoot` loads it on the main thread.
*
* Only the four models involved, rather than the whole registry — `WIKI.models` is what `reloadCache`
* and the resolver reach through, and importing the rest would pull the storage, search and mail
* models into a thread that is reading HTML.
*/
async function prepare(): Promise<void> {
await WIKI.ensureDb!()
WIKI.cache = new NodeCache({ checkperiod: 0 })
WIKI.models = { settings, locales, pages, pageLinks } as typeof WIKI.models
// -> Locales first: the site cache reads nothing from them, but a site's URL prefixes are resolved
// through the locale cache the moment the first link is read
await locales.reloadCache()
await sites.reloadCache()
}
export async function task(): Promise<void> {
await prepare()
let cursor: string | null = null
let processed = 0
WIKI.logger.info('Rebuilding page links...')
for (;;) {
/*
Walked by id rather than by offset: this reads every page in the wiki one batch at a time, and a
paged read that re-counts its way to each batch gets slower as it goes. Nothing is being written
to `pages` here, so the set is stable underneath it.
*/
const rows = await WIKI.db
.select({
id: pagesTable.id,
siteId: pagesTable.siteId,
locale: pagesTable.locale,
path: pagesTable.path,
editor: pagesTable.editor,
content: pagesTable.content,
relations: pagesTable.relations,
render: pagesTable.render
})
.from(pagesTable)
.where(cursor ? gt(pagesTable.id, cursor) : undefined)
.orderBy(asc(pagesTable.id))
.limit(BATCH_SIZE)
if (rows.length < 1) {
break
}
for (const row of rows) {
/*
One page at a time, and one failure does not stop the rest: this is a repair, and stopping at
the first page whose render will not parse would leave the wiki with the half of its links it
already had plus however far this got.
*/
try {
await pageLinks.refreshForPage(row)
} catch (err: any) {
WIKI.logger.warn(`Could not rebuild the links of page ${row.id}: ${err.message}`)
}
processed++
}
cursor = rows.at(-1)!.id
}
WIKI.logger.info(`Rebuilt the links of ${processed} page(s) [ COMPLETED ]`)
}