You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/api/schemas/page.ts

390 lines
13 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { pageHistoryActions } from '../../models/pageHistory.ts'
import type { FastifyInstance } from 'fastify'
/**
* A date that may not be set.
*
* An empty string counts as unset alongside null, because that is how the editor holds a date nobody
* has filled in — rejecting it would fail every save of a page that is not scheduled.
*/
const optionalDateTime = {
anyOf: [
{ type: 'string', format: 'date-time' },
{ type: 'string', maxLength: 0 },
{ type: 'null' }
],
description: 'Empty or null when there is no date.'
}
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* PAGE INPUT - The writable fields, used for both create and update
*/
app.addSchema({
$id: 'PageInput',
type: 'object',
properties: {
path: {
type: 'string',
maxLength: 255,
pattern: '^/?[a-zA-Z0-9-_/]*$',
description: 'Where the page lives, without a leading slash. Lowercased when stored.'
},
title: {
type: 'string',
minLength: 1,
maxLength: 255
},
description: {
type: 'string',
maxLength: 255
},
icon: {
type: 'string',
maxLength: 255
},
alias: {
type: 'string',
maxLength: 255,
pattern: '^[a-zA-Z0-9-_]*$'
},
locale: {
type: 'string',
maxLength: 10,
description: "The site's primary locale when absent."
},
editor: {
type: 'string',
maxLength: 255,
description:
'Which editor authored the content, e.g. `markdown`. `redirect` is a page with no body at all: it sends its reader elsewhere, is never searchable, and its content is the JSON below rather than a document.'
},
content: {
type: 'string',
description:
'The source, in whatever the editor writes. For a `redirect` page, `{ "kind": "page" | "url", "target": string, "showInterstitial": boolean }` — a page target is a rooted path within this wiki, a URL target a complete http(s) address.'
},
render: {
type: 'string',
description:
"The HTML the editor produced. Sanitized against the author's permissions before it is stored, and the table of contents and search text are derived from the result — so what comes back may differ from what was sent."
},
publishState: {
type: 'string',
enum: ['draft', 'published', 'scheduled']
},
publishStartDate: optionalDateTime,
publishEndDate: optionalDateTime,
isBrowsable: {
type: 'boolean'
},
isSearchable: {
type: 'boolean'
},
password: {
type: 'string',
maxLength: 255
},
relations: {
type: 'array',
items: {
type: 'object',
additionalProperties: true
}
},
tags: {
type: 'array',
items: {
type: 'string'
}
},
allowComments: { type: 'boolean' },
allowContributions: { type: 'boolean' },
allowRatings: { type: 'boolean' },
showSidebar: { type: 'boolean' },
showTags: { type: 'boolean' },
showToc: { type: 'boolean' },
tocDepth: {
type: 'object',
properties: {
min: { type: 'integer', minimum: 1, maximum: 6 },
max: { type: 'integer', minimum: 1, maximum: 6 }
}
},
scriptJsLoad: {
type: 'string',
description: 'Requires the `write:scripts` permission. Ignored without it.'
},
scriptJsUnload: {
type: 'string',
description: 'Requires the `write:scripts` permission. Ignored without it.'
},
scriptCss: {
type: 'string',
description: 'Requires the `write:styles` permission. Ignored without it.'
},
reasonForChange: {
type: 'string',
maxLength: 255,
description:
"Why this save is being made, as the editor's reason-for-change prompt collected it. Not stored on the page: it is recorded on the history version this save produces."
}
}
})
/**
* PAGE - A page as it is served back
*/
app.addSchema({
$id: 'Page',
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
path: { type: 'string' },
hash: {
type: 'string',
description: 'Hash of the path, which is how a page is addressed by URL.'
},
alias: { type: ['string', 'null'] },
title: { type: 'string' },
description: { type: ['string', 'null'] },
icon: { type: ['string', 'null'] },
locale: { type: 'string' },
editor: { type: 'string' },
contentType: { type: 'string' },
publishState: { type: 'string', enum: ['draft', 'published', 'scheduled'] },
publishStartDate: { type: ['string', 'null'], format: 'date-time' },
publishEndDate: { type: ['string', 'null'], format: 'date-time' },
isBrowsable: { type: 'boolean' },
isSearchable: { type: 'boolean' },
password: {
type: ['string', 'null'],
description:
'Only present for a requester who may edit the page — whoever can take the password off it. Absent otherwise, protected page or not.'
},
isLocked: {
type: 'boolean',
description:
'The page is password protected and this requester has not entered it, so `content`, `render` and `toc` were withheld. Unlock it with `POST …/unlock`.'
},
relations: {
type: 'array',
items: { type: 'object', additionalProperties: true }
},
tags: { type: 'array', items: { type: 'string' } },
toc: {
type: 'array',
description:
'Nested headings, derived from the stored render. Each carries its own `level` — the heading tag it came from — as well as its place in the tree, since which headings a contents list shows is a question about the tag rather than about the nesting.',
items: { type: 'object', additionalProperties: true }
},
render: { type: 'string' },
content: {
type: 'string',
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.'
},
allowComments: { type: 'boolean' },
allowContributions: { type: 'boolean' },
allowRatings: { type: 'boolean' },
showSidebar: { type: 'boolean' },
showTags: { type: 'boolean' },
showToc: { type: 'boolean' },
tocDepth: {
type: 'object',
properties: {
min: { type: 'integer' },
max: { type: 'integer' }
}
},
scriptJsLoad: { type: 'string' },
scriptJsUnload: { type: 'string' },
scriptCss: { type: 'string' },
navigationId: { type: ['string', 'null'] },
navigationMode: { type: 'string' },
authorId: { type: 'string', format: 'uuid' },
authorName: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
viewer: {
type: 'object',
description:
'Where the requester stands on this page: what they may do to it, whether they may suggest an edit, and whether they review it. Present when a page is fetched on its own — the page view draws its controls from this, rather than asking three further endpoints about a page it already has. Absent from a page returned by a save.',
properties: {
permissions: {
type: 'array',
items: { type: 'string' },
description:
'The page permissions held AT THIS PATH, as this readers groups rules decide. The same answer `pages/userPermissions` gives for the path.'
},
canSuggestEdits: {
type: 'boolean',
description:
'An enabled approval rule covers this page and names a group the requester is in, and the page allows contributions.'
},
hasOpenSuggestion: {
type: 'boolean',
description:
'The requester already has a suggestion waiting on this page, which they would carry on with rather than start again. Always false for a guest, whose suggestions are attributed to nobody.'
},
canReview: {
type: 'boolean',
description: 'The requester reviews this page. Always false without an account.'
},
isWatching: {
type: 'boolean',
description:
'The requester has asked to be told about changes to this page. Always false without an account, since a watch belongs to one.'
},
pendingSubmissions: {
type: 'array',
items: { $ref: 'PageEditSubmission#' },
description: 'What is waiting on this page, oldest first. Empty unless `canReview`.'
}
}
}
}
})
/**
* WATCHED PAGE - A page somebody asked to be told about, as their inbox lists it
*/
app.addSchema({
$id: 'WatchedPage',
type: 'object',
properties: {
pageId: { type: 'string', format: 'uuid' },
path: { type: 'string' },
locale: { type: 'string' },
title: { type: 'string' },
description: { type: ['string', 'null'] },
icon: { type: ['string', 'null'] },
updatedAt: {
type: 'string',
format: 'date-time',
description: 'When the page last changed, which is what watching it is about.'
},
watchedAt: {
type: 'string',
format: 'date-time',
description: 'When the caller started watching it.'
}
}
})
/**
* INCLUDED PAGE - Another page's render, as an include block draws it inside the page being read
*/
app.addSchema({
$id: 'IncludedPage',
type: 'object',
properties: {
path: {
type: 'string',
description: 'Slash-separated path of the page that was included.'
},
locale: {
type: 'string'
},
title: {
type: 'string'
},
isLocked: {
type: 'boolean',
description:
'The page is password protected and this reader has not entered it, so `render` is empty. An include does not offer the unlock prompt: the reader unlocks the page by opening it.'
},
render: {
type: 'string',
description: 'The stored HTML, already sanitised when the page was saved.'
}
}
})
/**
* PAGE HISTORY ENTRY - One version of a page, as the history timeline lists it
*/
app.addSchema({
$id: 'PageHistoryEntry',
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
action: {
type: 'string',
enum: [...pageHistoryActions],
description: 'What happened to the page. `moved` is a change of path or title.'
},
changedFields: {
type: 'array',
description:
'Which page fields the change touched, named as the page stores them. Empty for a creation or a deletion, where the whole page is the change.',
items: {
type: 'string'
}
},
reason: {
type: 'string',
description:
"Why the change was made, in the author's words. Empty when the site does not ask for a reason — see the `reasonForChange` site feature — or asked and was not answered."
},
versionDate: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
},
path: {
type: 'string',
description: 'Where the page was at the time, which is not necessarily where it is now.'
},
title: {
type: 'string'
},
author: {
type: 'object',
description: 'Who made the change. Null id and empty name once that account is deleted.',
properties: {
id: {
type: ['string', 'null'],
format: 'uuid'
},
name: {
type: 'string'
},
email: {
type: 'string'
}
}
}
}
})
/**
* PAGE HISTORY VERSION - The same, with the source it held: one side of a diff
*/
app.addSchema({
$id: 'PageHistoryVersion',
type: 'object',
allOf: [
{ $ref: 'PageHistoryEntry#' },
{
type: 'object',
properties: {
content: {
type: 'string',
description: 'The page source as of this version.'
},
meta: {
type: 'object',
additionalProperties: true,
description:
'The rest of the page as it stood: description, icon, tags, publish state and dates, relations, scripts, config, editor and content type.'
}
}
}
]
})
}