mirror of https://github.com/requarks/wiki
parent
32a656e7be
commit
8f72bd6d72
@ -0,0 +1,263 @@
|
||||
import { chunk } from 'es-toolkit/array'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import type { SitemapPage } from '../models/pages.ts'
|
||||
|
||||
/**
|
||||
* The sitemap protocol's own ceiling: 50,000 URLs, and 50MB uncompressed, per file. Past it the
|
||||
* document served is a sitemap index naming numbered parts of this size instead.
|
||||
*/
|
||||
const MAX_URLS_PER_FILE = 50000
|
||||
|
||||
/**
|
||||
* How long a crawler — or anything caching in front of this — may hold one of these files.
|
||||
*
|
||||
* `public` whoever asked: neither document varies by requester. The sitemap is `listForSitemap`'s
|
||||
* answer, which is the guests group's view and nobody else's, and robots.txt is a site setting — so a
|
||||
* shared cache has nothing to leak from one reader to another. Ten minutes because the alternative to
|
||||
* a slightly stale sitemap is re-reading every page of a wiki for a file a crawler fetches a few
|
||||
* times a day.
|
||||
*/
|
||||
const ROOT_FILE_CACHE = 'public, max-age=600'
|
||||
|
||||
/** Everything written into the document goes through here, including anything that came off a header. */
|
||||
function xmlEscape(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin a `<loc>` — or robots.txt's `Sitemap:` line — is written against.
|
||||
*
|
||||
* The requester's own, and deliberately not the site's configured hostname: a sitemap may only list
|
||||
* URLs on the host it was itself fetched from — a crawler discards the rest as a cross-submission —
|
||||
* so the host in the request IS the answer, whether the site is bound to it or is the catch-all `*`.
|
||||
* It comes off a header and is therefore whatever the client said, which is why it is escaped before
|
||||
* it reaches the XML. It cannot carry a line break into robots.txt: a header value holding one is
|
||||
* rejected by the HTTP parser long before this.
|
||||
*/
|
||||
function originOf(req: FastifyRequest): string {
|
||||
return `${req.protocol}://${req.host}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a page is, absolute and ready to be written into an element.
|
||||
*
|
||||
* `urlFor` is what decides the path, the same as for every link the wiki makes of its own pages, so
|
||||
* the locale prefix is bracketed exactly where that site's settings put one. A page path is held to
|
||||
* `[a-zA-Z0-9-_/]` when it is saved, but a locale's short code is an administrator's to alias, so the
|
||||
* result is percent-encoded before it is escaped.
|
||||
*/
|
||||
function locOf(origin: string, siteId: string, page: SitemapPage): string {
|
||||
return xmlEscape(
|
||||
`${origin}${encodeURI(WIKI.models.pages.urlFor(siteId, page.locale, page.path))}`
|
||||
)
|
||||
}
|
||||
|
||||
/** W3C datetime, which is what `<lastmod>` takes. Seconds: a crawler has no use for the nanoseconds. */
|
||||
function lastmodOf(date: Date): string {
|
||||
return date.toTemporalInstant().toString({ smallestUnit: 'second' })
|
||||
}
|
||||
|
||||
/**
|
||||
* The translations of each page, keyed by locale group.
|
||||
*
|
||||
* Built over the WHOLE list rather than the part being rendered, so that a page whose other languages
|
||||
* fell into a different numbered file still names all of them. A page with no counterparts has a null
|
||||
* group and is absent from here — nulls are distinct, so they would otherwise all be one group.
|
||||
*/
|
||||
function localeGroupsOf(pages: SitemapPage[]): Map<string, SitemapPage[]> {
|
||||
const groups = new Map<string, SitemapPage[]>()
|
||||
for (const page of pages) {
|
||||
if (!page.localeGroupId) {
|
||||
continue
|
||||
}
|
||||
const group = groups.get(page.localeGroupId)
|
||||
if (group) {
|
||||
group.push(page)
|
||||
} else {
|
||||
groups.set(page.localeGroupId, [page])
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* One file of page URLs.
|
||||
*
|
||||
* `changefreq` and `priority` are deliberately absent: no search engine has read either for years,
|
||||
* and the wiki has nothing honest to put in them — every page would claim the same numbers.
|
||||
*
|
||||
* What is here instead is `xhtml:link`, one per language a page exists in, which is how a crawler is
|
||||
* told that two paths are the same page rather than duplicates of each other. Emitted only where a
|
||||
* page actually has counterparts, and including the page itself, which is what the annotation calls
|
||||
* for. Only counterparts that are in this document at all: a translation the guests group may not
|
||||
* read is not an alternate a crawler should be sent to.
|
||||
*/
|
||||
function renderUrlset(
|
||||
origin: string,
|
||||
siteId: string,
|
||||
pages: SitemapPage[],
|
||||
all: SitemapPage[]
|
||||
): string {
|
||||
const groups = localeGroupsOf(all)
|
||||
const lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">'
|
||||
]
|
||||
for (const page of pages) {
|
||||
lines.push(' <url>')
|
||||
lines.push(` <loc>${locOf(origin, siteId, page)}</loc>`)
|
||||
lines.push(` <lastmod>${lastmodOf(page.updatedAt)}</lastmod>`)
|
||||
const alternates = page.localeGroupId ? (groups.get(page.localeGroupId) ?? []) : []
|
||||
if (alternates.length > 1) {
|
||||
for (const alternate of alternates) {
|
||||
lines.push(
|
||||
` <xhtml:link rel="alternate" hreflang="${xmlEscape(alternate.locale)}" href="${locOf(origin, siteId, alternate)}"/>`
|
||||
)
|
||||
}
|
||||
}
|
||||
lines.push(' </url>')
|
||||
}
|
||||
lines.push('</urlset>')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* The index served in place of the list once it no longer fits in one file.
|
||||
*
|
||||
* The parts are `?p=N` on this same path rather than files of their own, because a sitemap may only
|
||||
* list URLs at or below its own directory — a part under `/_sitemap/` could name nothing outside it —
|
||||
* and because the reserved root files a crawler may ask for are a fixed set that no numbered name
|
||||
* could join. A query string is a URL like any other to a crawler.
|
||||
*/
|
||||
function renderIndex(origin: string, parts: SitemapPage[][]): string {
|
||||
const lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
||||
]
|
||||
parts.forEach((pages, index) => {
|
||||
const newest = pages.reduce<Date | null>(
|
||||
(latest, page) => (!latest || page.updatedAt > latest ? page.updatedAt : latest),
|
||||
null
|
||||
)
|
||||
lines.push(' <sitemap>')
|
||||
lines.push(` <loc>${xmlEscape(`${origin}/sitemap.xml?p=${index + 1}`)}</loc>`)
|
||||
if (newest) {
|
||||
lines.push(` <lastmod>${lastmodOf(newest)}</lastmod>`)
|
||||
}
|
||||
lines.push(' </sitemap>')
|
||||
})
|
||||
lines.push('</sitemapindex>')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function sendXml(reply: FastifyReply, body: string) {
|
||||
return reply
|
||||
.header('Cache-Control', ROOT_FILE_CACHE)
|
||||
.type('application/xml; charset=utf-8')
|
||||
.send(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Root file routes: `robots.txt` and `sitemap.xml`.
|
||||
*
|
||||
* The two files a crawler asks for at the root by convention rather than because the wiki has a page
|
||||
* there — which is why they are registered at the root rather than under one of the server's own
|
||||
* prefixes, and why `RESERVED_ROOT_FILES` holds both names: without that, the SEO hook would treat
|
||||
* `/sitemap.xml` as a page path and send a crawler off to the site's locale prefix, and `/robots.txt`
|
||||
* would be redirected to the page `robots`, `txt` being a page extension on a default site.
|
||||
* (`favicon.ico` is the third such name and is served by `@fastify/favicon`.)
|
||||
*
|
||||
* Both are public, both answer for whichever site the request's host resolves to, and both are driven
|
||||
* by that site's own settings in the admin area's **General → SEO** card.
|
||||
*/
|
||||
async function routes(app: FastifyInstance) {
|
||||
/**
|
||||
* robots.txt
|
||||
*
|
||||
* Two of the three settings on that card are about crawlers, and robots.txt can carry exactly one
|
||||
* of them:
|
||||
*
|
||||
* - **Allow Indexing** off becomes `Disallow: /`, which is as close as this file gets. Worth being
|
||||
* clear that it is not the same instruction: `Disallow` says do not CRAWL, `noindex` says do not
|
||||
* INDEX, and a page that is never crawled can still be listed from its inbound links alone. The
|
||||
* thorough form of the setting is the `X-Robots-Tag` on every HTML response — `robotsTagFor` in
|
||||
* `index.ts` — and this line is the coarse one that keeps a crawler off the wiki to begin with.
|
||||
* - **Allow Follow** has no expression here at all: `nofollow` is a directive about a document a
|
||||
* crawler is holding, and robots.txt has no concept of one. That toggle is honoured by the same
|
||||
* header, and this route does not pretend to carry it.
|
||||
* - **Allow Sitemap** adds the `Sitemap:` line, which is how a crawler that was given nothing but a
|
||||
* hostname finds the sitemap at all. Omitted when indexing is off, since pointing a crawler at an
|
||||
* index of pages it has just been told not to crawl says nothing coherent.
|
||||
*
|
||||
* Nothing per-page is ever written here — not the pages the guests group may not read, and not the
|
||||
* ones marked out of search results. robots.txt is world-readable, so a `Disallow` naming a path is
|
||||
* a published list of what a wiki considers worth hiding. What keeps those out of a crawler's way is
|
||||
* that they are absent from the sitemap and refused when asked for.
|
||||
*
|
||||
* Served whatever the settings say — there is no toggle for having a robots.txt, because a site
|
||||
* always has an answer to the question it asks. Only a host that resolves to no site at all 404s.
|
||||
*/
|
||||
app.get('/robots.txt', async (req, reply) => {
|
||||
const site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
|
||||
if (!site) {
|
||||
return reply.notFound()
|
||||
}
|
||||
|
||||
const lines = ['User-agent: *']
|
||||
if (site.config?.robots?.index) {
|
||||
lines.push('Allow: /')
|
||||
if (site.config.sitemap) {
|
||||
lines.push('', `Sitemap: ${originOf(req)}/sitemap.xml`)
|
||||
}
|
||||
} else {
|
||||
lines.push('Disallow: /')
|
||||
}
|
||||
|
||||
return reply
|
||||
.header('Cache-Control', ROOT_FILE_CACHE)
|
||||
.type('text/plain; charset=utf-8')
|
||||
.send(`${lines.join('\n')}\n`)
|
||||
})
|
||||
|
||||
/**
|
||||
* sitemap.xml
|
||||
*
|
||||
* Off for a site whose **Allow Sitemap** is unticked, on for a new one.
|
||||
*/
|
||||
app.get<{ Querystring: { p?: string } }>('/sitemap.xml', async (req, reply) => {
|
||||
const site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
|
||||
if (!site?.config?.sitemap) {
|
||||
// -> A site with the setting off has no sitemap rather than an empty one, and 404 is what tells
|
||||
// a crawler to stop asking for it
|
||||
return reply.notFound()
|
||||
}
|
||||
|
||||
const pages = await WIKI.models.pages.listForSitemap(site.id)
|
||||
const origin = originOf(req)
|
||||
// -> A site with nothing a guest may read still answers, with an empty list: it is the truthful
|
||||
// answer, and a 404 would read as the feature being broken rather than as the wiki being shut
|
||||
const parts = pages.length > 0 ? chunk(pages, MAX_URLS_PER_FILE) : [[]]
|
||||
|
||||
if (req.query.p === undefined) {
|
||||
return sendXml(
|
||||
reply,
|
||||
parts.length > 1
|
||||
? renderIndex(origin, parts)
|
||||
: renderUrlset(origin, site.id, parts[0]!, pages)
|
||||
)
|
||||
}
|
||||
|
||||
const part = Number.parseInt(req.query.p, 10)
|
||||
if (!Number.isInteger(part) || part < 1 || part > parts.length) {
|
||||
return reply.notFound()
|
||||
}
|
||||
return sendXml(reply, renderUrlset(origin, site.id, parts[part - 1]!, pages))
|
||||
})
|
||||
}
|
||||
|
||||
export default routes
|
||||
Loading…
Reference in new issue