mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 22:01:58 -05:00
650 lines
24 KiB
TypeScript
650 lines
24 KiB
TypeScript
import { audit } from '../helpers/audit.ts'
|
||
import { maskSensitiveProps } from '../helpers/common.ts'
|
||
import { mayOnPage } from './pages.ts'
|
||
import { COMMENT_MAX_LENGTH, COMMENT_MIN_LENGTH } from '../models/comments.ts'
|
||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||
import type { CommentsProviderInput } from '../models/comments.ts'
|
||
import type { RulePageRef } from '../helpers/pageRules.ts'
|
||
|
||
const siteIdParam = {
|
||
type: 'object',
|
||
properties: {
|
||
siteId: {
|
||
type: 'string',
|
||
format: 'uuid'
|
||
}
|
||
},
|
||
required: ['siteId']
|
||
}
|
||
|
||
const pageIdParam = {
|
||
type: 'object',
|
||
properties: {
|
||
siteId: { type: 'string', format: 'uuid' },
|
||
pageId: { type: 'string', format: 'uuid' }
|
||
},
|
||
required: ['siteId', 'pageId']
|
||
}
|
||
|
||
const commentIdParam = {
|
||
type: 'object',
|
||
properties: {
|
||
siteId: { type: 'string', format: 'uuid' },
|
||
commentId: { type: 'string', format: 'uuid' }
|
||
},
|
||
required: ['siteId', 'commentId']
|
||
}
|
||
|
||
/**
|
||
* A roughly-shaped email address, for the one a guest has to leave.
|
||
*
|
||
* Deliberately not a proof that the address exists — nothing here sends to it. It is what Akismet is
|
||
* given and what a future moderation screen would show, and the check is here so that a required
|
||
* field cannot be satisfied with a space.
|
||
*/
|
||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||
|
||
/**
|
||
* Comments API Routes
|
||
*
|
||
* Two halves, and they answer to different permissions.
|
||
*
|
||
* **The configuration half** — `GET`/`PUT /sites/:siteId/comments` — is the admin area's Comments
|
||
* screen: which provider this site uses and how it is configured. `manage:sites`, like every other
|
||
* per-site configuration screen.
|
||
*
|
||
* **The comments themselves** are the built-in provider, and carry NO route-level `permissions`: what
|
||
* governs them is `read:comments`, `write:comments` and `manage:comments`, which are PAGE rule
|
||
* permissions and cannot be enforced by a hook that only reads the group-wide list. Every one of these
|
||
* routes resolves the page first and asks `mayOnPage` about it — see `helpers/pageRules.ts` for how a
|
||
* rule is chosen.
|
||
*/
|
||
async function routes(app: FastifyInstance) {
|
||
/**
|
||
* GET SITE COMMENTS CONFIGURATION
|
||
*/
|
||
app.get<{ Params: { siteId: string } }>(
|
||
'/sites/:siteId/comments',
|
||
{
|
||
config: {
|
||
permissions: ['manage:sites']
|
||
},
|
||
schema: {
|
||
summary: 'Get the comments configuration of a site',
|
||
description:
|
||
'The provider this site uses, plus one entry per provider that could be selected — the wiki’s own first, then every module installed in `modules/comments` — each with the values this site has configured for it.\n\nSensitive props are masked: the built-in provider’s Akismet key comes back as a fixed placeholder, and sending that placeholder back keeps the stored key.',
|
||
tags: ['Comments'],
|
||
params: siteIdParam,
|
||
response: {
|
||
200: {
|
||
description: 'Comments configuration of the site',
|
||
type: 'object',
|
||
properties: {
|
||
provider: {
|
||
type: 'string',
|
||
description:
|
||
'Key of the selected provider, or an empty string when this site has picked none. This is what is stored rather than what is in force: `isAllowed` is the other half.'
|
||
},
|
||
isAllowed: {
|
||
type: 'boolean',
|
||
description:
|
||
'Whether the site allows comments at all — the switch under General → Features. False makes the selection below have no effect, which is worth saying on the screen that edits it.'
|
||
},
|
||
providers: {
|
||
type: 'array',
|
||
items: { $ref: 'CommentsProvider#' }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
async (req, reply) => {
|
||
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
||
if (!site) {
|
||
return reply.notFound('Site does not exist.')
|
||
}
|
||
const providers = WIKI.models.comments.getSiteProviders(req.params.siteId)
|
||
return {
|
||
// -> What is STORED. The screen edits the selection whether or not the site-wide switch is
|
||
// on, and says so through `isAllowed` rather than by reporting nothing selected.
|
||
provider: WIKI.models.comments.selectedProvider(req.params.siteId),
|
||
isAllowed: WIKI.models.comments.isAllowed(req.params.siteId),
|
||
// -> Masking is the last thing that happens on the way out, here and nowhere earlier: the
|
||
// model hands out the real values because that is what the spam check reads its key from
|
||
providers: providers.map((provider) => ({
|
||
...provider,
|
||
config: maskSensitiveProps(provider.props, provider.config)
|
||
}))
|
||
}
|
||
}
|
||
)
|
||
|
||
/**
|
||
* UPDATE SITE COMMENTS CONFIGURATION
|
||
*/
|
||
app.put<{
|
||
Params: { siteId: string }
|
||
Body: { provider?: string; providers?: CommentsProviderInput[] }
|
||
}>(
|
||
'/sites/:siteId/comments',
|
||
{
|
||
config: {
|
||
permissions: ['manage:sites']
|
||
},
|
||
schema: {
|
||
summary: 'Update the comments configuration of a site',
|
||
description:
|
||
'Selects the provider and writes whatever configuration came with it. The providers not mentioned keep what they had, so trying another one and coming back finds a form that is still filled in.\n\nEverything is validated before any of it is written, so a rejected request changes nothing. A saved change applies to the next page view, on every instance.',
|
||
tags: ['Comments'],
|
||
params: siteIdParam,
|
||
body: {
|
||
type: 'object',
|
||
properties: {
|
||
provider: {
|
||
type: 'string',
|
||
maxLength: 255,
|
||
description:
|
||
'Key of the provider to use, or an empty string to turn comments off for this site.'
|
||
},
|
||
providers: {
|
||
type: 'array',
|
||
items: { $ref: 'CommentsProviderInput#' }
|
||
}
|
||
}
|
||
},
|
||
response: {
|
||
200: {
|
||
description: 'Comments configuration updated successfully',
|
||
type: 'object',
|
||
properties: {
|
||
ok: { type: 'boolean' },
|
||
message: { type: 'string' }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
async (req, reply) => {
|
||
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
||
if (!site) {
|
||
return reply.notFound('Site does not exist.')
|
||
}
|
||
if (
|
||
req.body.provider !== undefined &&
|
||
req.body.provider.length > 0 &&
|
||
!WIKI.models.comments.getDefinition(req.body.provider)
|
||
) {
|
||
return reply.badRequest(`There is no comments provider called "${req.body.provider}".`)
|
||
}
|
||
// -> Validated as a whole first: the screen saves every provider at once, and a partially
|
||
// applied configuration is worse than a refused one
|
||
for (const patch of req.body.providers ?? []) {
|
||
const invalid = WIKI.models.comments.validateProvider(patch)
|
||
if (invalid) {
|
||
return reply.badRequest(invalid)
|
||
}
|
||
}
|
||
|
||
await WIKI.models.comments.updateSiteConfig(req.params.siteId, req.body)
|
||
|
||
/*
|
||
Which provider was selected and which fields were touched — never the values. An Akismet key
|
||
is exactly the kind of thing `meta` must not carry, and the rest of it names an account at a
|
||
third party rather than anything about this wiki.
|
||
*/
|
||
await audit(req, 'admin', 'updateComments', {
|
||
siteId: req.params.siteId,
|
||
provider: req.body.provider,
|
||
changedProviders: (req.body.providers ?? []).map((patch) => ({
|
||
key: patch.key,
|
||
changedFields: Object.keys(patch.config ?? {})
|
||
}))
|
||
})
|
||
|
||
return { ok: true, message: 'Comments configuration updated successfully.' }
|
||
}
|
||
)
|
||
|
||
/**
|
||
* LIST COMMENTS OF A PAGE
|
||
*/
|
||
/*
|
||
No route-level `permissions`: `read:comments` is granted by a group's page RULES, which the hook
|
||
in `index.ts` cannot see. Checked against this page below instead.
|
||
*/
|
||
app.get<{ Params: { siteId: string; pageId: string } }>(
|
||
'/sites/:siteId/pages/:pageId/comments',
|
||
{
|
||
schema: {
|
||
summary: 'List the comments of a page',
|
||
description:
|
||
'Every comment on the page, oldest first and flat — the one level of nesting is assembled by the client from `parentId`, which keeps a reply beside the comment it answers however old that comment is.\n\nOnly for a site using the built-in provider; a site whose discussions live at a third party answers 404 here. Needs `read:comments` on the page, which the guests group may hold.\n\n`mentions` resolves the handles written in these comments, so that a mention is drawn as a link to the right person without a lookup per `@`.',
|
||
tags: ['Comments'],
|
||
params: pageIdParam,
|
||
response: {
|
||
200: {
|
||
description: 'The comments on this page',
|
||
type: 'object',
|
||
properties: {
|
||
comments: {
|
||
type: 'array',
|
||
items: { $ref: 'Comment#' }
|
||
},
|
||
mentions: {
|
||
type: 'array',
|
||
items: { $ref: 'MentionTarget#' }
|
||
},
|
||
total: {
|
||
type: 'integer',
|
||
description:
|
||
'How many comments the page has. The same as the length of `comments` unless the page has more than the list caps at, which is the one case where the count beside the Talk tab must not be taken from the list.'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
async (req, reply) => {
|
||
const page = await requireBuiltInPage(req, reply)
|
||
if (!page) {
|
||
return reply
|
||
}
|
||
if (!mayOnPage(req, 'read:comments', page)) {
|
||
return reply.forbidden('You are not allowed to read the comments of this page.')
|
||
}
|
||
const comments = await WIKI.models.comments.listForPage(page.id)
|
||
const [mentions, total] = await Promise.all([
|
||
WIKI.models.comments.resolveMentions(comments.map((c) => c.content)),
|
||
WIKI.models.comments.countForPage(page.id)
|
||
])
|
||
return { comments, mentions, total }
|
||
}
|
||
)
|
||
|
||
/**
|
||
* POST A COMMENT
|
||
*/
|
||
// -> No route-level permissions: `write:comments` is a page rule. See the note above.
|
||
app.post<{
|
||
Params: { siteId: string; pageId: string }
|
||
Body: { content: string; parentId?: string; authorName?: string; authorEmail?: string }
|
||
}>(
|
||
'/sites/:siteId/pages/:pageId/comments',
|
||
{
|
||
schema: {
|
||
summary: 'Post a comment on a page',
|
||
description:
|
||
'Needs `write:comments` on the page. A rule may grant it to the guests group, in which case a name and an email address are required of whoever is posting — the email is stored but never served, and is what the spam check is given.\n\nThe site’s posting cooldown applies to everybody who is not a moderator, counted per account and per address for a guest; going over it answers 429 with `Retry-After`. With an Akismet key configured, a comment Akismet calls spam is refused.',
|
||
tags: ['Comments'],
|
||
params: pageIdParam,
|
||
body: {
|
||
type: 'object',
|
||
required: ['content'],
|
||
properties: {
|
||
content: {
|
||
type: 'string',
|
||
minLength: COMMENT_MIN_LENGTH,
|
||
maxLength: COMMENT_MAX_LENGTH,
|
||
description: 'Markdown source. Raw HTML in it is escaped rather than rendered.'
|
||
},
|
||
parentId: {
|
||
type: 'string',
|
||
format: 'uuid',
|
||
description:
|
||
'The comment being answered. Replying to a reply attaches the answer to that reply’s own parent — the thread is one level deep.'
|
||
},
|
||
authorName: {
|
||
type: 'string',
|
||
minLength: 1,
|
||
maxLength: 255,
|
||
description:
|
||
'Required of a guest, ignored for a signed-in author (the account has one).'
|
||
},
|
||
authorEmail: {
|
||
type: 'string',
|
||
maxLength: 255,
|
||
description: 'Required of a guest. Stored, never served.'
|
||
}
|
||
}
|
||
},
|
||
response: {
|
||
201: {
|
||
description: 'The comment as it was stored',
|
||
$ref: 'Comment#'
|
||
}
|
||
}
|
||
}
|
||
},
|
||
async (req, reply) => {
|
||
const page = await requireBuiltInPage(req, reply)
|
||
if (!page) {
|
||
return reply
|
||
}
|
||
if (!page.allowComments) {
|
||
return reply.forbidden('Comments are turned off for this page.')
|
||
}
|
||
if (!mayOnPage(req, 'write:comments', page)) {
|
||
return reply.forbidden('You are not allowed to comment on this page.')
|
||
}
|
||
|
||
const user = req.session?.authenticated ? req.session.user : null
|
||
let authorName = user?.name ?? ''
|
||
let authorEmail = user?.email ?? ''
|
||
if (!user) {
|
||
/*
|
||
A guest. Both fields are required here rather than left to the schema, because they are
|
||
required only of a guest: a signed-in author has a name and an address on their account, and
|
||
a form that asked them for both again would be asking them to type something the wiki
|
||
already knows and would then have two answers for.
|
||
*/
|
||
authorName = (req.body.authorName ?? '').trim()
|
||
authorEmail = (req.body.authorEmail ?? '').trim()
|
||
if (authorName.length < 1) {
|
||
return reply.badRequest('A name is required to comment as a guest.')
|
||
}
|
||
if (!EMAIL_PATTERN.test(authorEmail)) {
|
||
return reply.badRequest('A valid email address is required to comment as a guest.')
|
||
}
|
||
}
|
||
|
||
const cooldown = await consumeCooldown(req, page)
|
||
if (cooldown > 0) {
|
||
reply.header('Retry-After', String(cooldown))
|
||
return reply.tooManyRequests(
|
||
`You are posting too quickly. Try again in ${cooldown} second(s).`
|
||
)
|
||
}
|
||
|
||
const origin = `${req.protocol}://${req.hostname}`
|
||
const isSpam = await WIKI.models.comments.isSpam(req.params.siteId, {
|
||
content: req.body.content,
|
||
authorName,
|
||
authorEmail,
|
||
authorIP: req.ip,
|
||
userAgent: req.headers['user-agent'] ?? '',
|
||
referrer: req.headers.referer ?? '',
|
||
permalink: `${origin}/${page.path}`,
|
||
isGuest: !user
|
||
})
|
||
if (isSpam) {
|
||
/*
|
||
Refused rather than held: there is no moderation queue yet, and a comment nobody can see
|
||
and nobody is told about is worse than one that was turned away with a reason. `meta` on
|
||
the row is where a queue would go when there is one.
|
||
*/
|
||
return reply.badRequest('This comment was flagged as spam and was not posted.')
|
||
}
|
||
|
||
let comment
|
||
try {
|
||
comment = await WIKI.models.comments.create({
|
||
pageId: page.id,
|
||
parentId: req.body.parentId ?? null,
|
||
content: req.body.content,
|
||
authorId: user?.id ?? null,
|
||
authorName,
|
||
authorEmail: user ? '' : authorEmail,
|
||
authorIP: req.ip
|
||
})
|
||
} catch (err: any) {
|
||
return reply.badRequest(err.message)
|
||
}
|
||
|
||
await audit(req, 'comment', 'createComment', {
|
||
commentId: comment.id,
|
||
pageId: page.id,
|
||
path: page.path,
|
||
locale: page.locale,
|
||
isReply: Boolean(comment.parentId),
|
||
isGuest: comment.isGuest
|
||
})
|
||
|
||
reply.code(201)
|
||
return comment
|
||
}
|
||
)
|
||
|
||
/**
|
||
* EDIT A COMMENT
|
||
*/
|
||
// -> No route-level permissions: the two that matter here are page rules. See the note above.
|
||
app.put<{ Params: { siteId: string; commentId: string }; Body: { content: string } }>(
|
||
'/sites/:siteId/comments/:commentId',
|
||
{
|
||
schema: {
|
||
summary: 'Edit a comment',
|
||
description:
|
||
'Whoever holds `manage:comments` on the page may edit any comment on it; everybody else may edit their own, and only while they still hold `write:comments` there.\n\nA guest cannot edit at all: there is no session that identifies them as the author, so `their own` has nothing to mean.',
|
||
tags: ['Comments'],
|
||
params: commentIdParam,
|
||
body: {
|
||
type: 'object',
|
||
required: ['content'],
|
||
properties: {
|
||
content: {
|
||
type: 'string',
|
||
minLength: COMMENT_MIN_LENGTH,
|
||
maxLength: COMMENT_MAX_LENGTH
|
||
}
|
||
}
|
||
},
|
||
response: {
|
||
200: {
|
||
description: 'The comment as it now stands',
|
||
$ref: 'Comment#'
|
||
}
|
||
}
|
||
}
|
||
},
|
||
async (req, reply) => {
|
||
const comment = await requireWritableComment(req, reply)
|
||
if (!comment) {
|
||
return reply
|
||
}
|
||
const updated = await WIKI.models.comments.update(comment.id, req.body.content)
|
||
if (!updated) {
|
||
return reply.notFound('This comment does not exist.')
|
||
}
|
||
await audit(req, 'comment', 'updateComment', {
|
||
commentId: comment.id,
|
||
pageId: comment.pageId,
|
||
path: comment.path,
|
||
isOwn: comment.authorId === req.session?.user?.id
|
||
})
|
||
return updated
|
||
}
|
||
)
|
||
|
||
/**
|
||
* DELETE A COMMENT
|
||
*/
|
||
// -> No route-level permissions: the two that matter here are page rules. See the note above.
|
||
app.delete<{ Params: { siteId: string; commentId: string } }>(
|
||
'/sites/:siteId/comments/:commentId',
|
||
{
|
||
schema: {
|
||
summary: 'Delete a comment',
|
||
description:
|
||
'Same rule as editing: `manage:comments` on the page deletes any comment, `write:comments` deletes your own.\n\nThe replies underneath go with it. A reply exists to answer something, and left behind it is half of a conversation nobody can read.',
|
||
tags: ['Comments'],
|
||
params: commentIdParam,
|
||
response: {
|
||
200: {
|
||
description: 'Comment deleted successfully',
|
||
type: 'object',
|
||
properties: {
|
||
ok: { type: 'boolean' },
|
||
deleted: {
|
||
type: 'integer',
|
||
description: 'How many comments went, the replies underneath included.'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
async (req, reply) => {
|
||
const comment = await requireWritableComment(req, reply)
|
||
if (!comment) {
|
||
return reply
|
||
}
|
||
const deleted = await WIKI.models.comments.remove(comment.id)
|
||
await audit(req, 'comment', 'deleteComment', {
|
||
commentId: comment.id,
|
||
pageId: comment.pageId,
|
||
path: comment.path,
|
||
deleted,
|
||
isOwn: comment.authorId === req.session?.user?.id
|
||
})
|
||
return { ok: true, deleted }
|
||
}
|
||
)
|
||
|
||
/**
|
||
* SEARCH MENTIONABLE USERS
|
||
*/
|
||
app.get<{ Params: { siteId: string }; Querystring: { q?: string } }>(
|
||
'/sites/:siteId/comments/mentions',
|
||
{
|
||
schema: {
|
||
summary: 'Find users to mention in a comment',
|
||
description:
|
||
'What the `@` in a comment box completes against: users who have set a handle, matched on the handle or the display name.\n\nNeeds a signed-in session, and nothing else — a handle and a display name are what every comment already shows, but answering this to anybody at all would make it a way to enumerate the wiki’s users. A guest who knows a handle can still type it; it resolves when the comment is drawn.',
|
||
tags: ['Comments'],
|
||
params: siteIdParam,
|
||
querystring: {
|
||
type: 'object',
|
||
properties: {
|
||
q: {
|
||
type: 'string',
|
||
maxLength: 64,
|
||
description: 'What has been typed after the `@`. Empty lists the first few handles.'
|
||
}
|
||
}
|
||
},
|
||
response: {
|
||
200: {
|
||
description: 'Users that can be mentioned',
|
||
type: 'array',
|
||
items: { $ref: 'MentionTarget#' }
|
||
}
|
||
}
|
||
}
|
||
},
|
||
async (req, reply) => {
|
||
if (!req.session?.authenticated) {
|
||
return reply.unauthorized('You must be signed in to look up users to mention.')
|
||
}
|
||
return WIKI.models.comments.searchHandles(req.query.q ?? '')
|
||
}
|
||
)
|
||
}
|
||
|
||
/**
|
||
* The page a request is about, once it is established that this site's comments are the wiki's own.
|
||
*
|
||
* Both questions answer 404 rather than anything more specific. A site using Disqus has no comments
|
||
* here to have an opinion about, and a page id that is not on this site is not this caller's to be
|
||
* told about.
|
||
*
|
||
* @returns The page, or null once it has sent the reply itself
|
||
*/
|
||
async function requireBuiltInPage(
|
||
req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>,
|
||
reply: FastifyReply
|
||
) {
|
||
if (!WIKI.models.comments.usesBuiltIn(req.params.siteId)) {
|
||
reply.notFound('This site does not use the built-in comments provider.')
|
||
return null
|
||
}
|
||
const page = await WIKI.models.comments.pageRef(req.params.siteId, req.params.pageId)
|
||
if (!page) {
|
||
reply.notFound('This page does not exist.')
|
||
return null
|
||
}
|
||
// -> Carrying the site, since everything below asks a page rule about this page and a rule may be
|
||
// limited to particular sites
|
||
return { ...page, siteId: req.params.siteId }
|
||
}
|
||
|
||
/**
|
||
* The comment a request is about, once it is established that the caller may change it.
|
||
*
|
||
* `manage:comments` on the page is the moderator's answer and covers anything on it. Otherwise it has
|
||
* to be the caller's own comment AND they have to still hold `write:comments` there — a rule that was
|
||
* taken away takes the editing of what was written under it with it.
|
||
*
|
||
* @returns The comment, or null once it has sent the reply itself
|
||
*/
|
||
async function requireWritableComment(
|
||
req: FastifyRequest<{ Params: { siteId: string; commentId: string } }>,
|
||
reply: FastifyReply
|
||
) {
|
||
if (!WIKI.models.comments.usesBuiltIn(req.params.siteId)) {
|
||
reply.notFound('This site does not use the built-in comments provider.')
|
||
return null
|
||
}
|
||
const comment = await WIKI.models.comments.getWithPage(req.params.commentId, req.params.siteId)
|
||
if (!comment) {
|
||
reply.notFound('This comment does not exist.')
|
||
return null
|
||
}
|
||
const page = {
|
||
siteId: req.params.siteId,
|
||
path: comment.path,
|
||
locale: comment.locale,
|
||
tags: comment.tags ?? []
|
||
}
|
||
if (mayOnPage(req, 'manage:comments', page)) {
|
||
return comment
|
||
}
|
||
const userId = req.session?.authenticated ? req.session.user?.id : null
|
||
if (!userId || comment.authorId !== userId || !mayOnPage(req, 'write:comments', page)) {
|
||
reply.forbidden('You are not allowed to modify this comment.')
|
||
return null
|
||
}
|
||
return comment
|
||
}
|
||
|
||
/**
|
||
* Count this post against the site's cooldown, and say how long is left of it.
|
||
*
|
||
* Per account, and per address for a guest — an office behind one address shares a counter only where
|
||
* the wiki has no better way of telling two people apart, which is exactly the case the cooldown is
|
||
* for. The counter is the same postgres-backed one the login limit uses, so two instances behind a
|
||
* load balancer agree about it.
|
||
*
|
||
* Moderators are exempt, along with `manage:system` as everywhere: `manage:comments` on this page is
|
||
* the permission to clean up after other people, and answering five threads in a row is what that
|
||
* looks like.
|
||
*
|
||
* @returns Seconds the caller must wait, or 0 when the post may go ahead
|
||
*/
|
||
async function consumeCooldown(
|
||
req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>,
|
||
page: RulePageRef
|
||
): Promise<number> {
|
||
const seconds = WIKI.models.comments.cooldownFor(req.params.siteId)
|
||
if (seconds < 1 || mayOnPage(req, 'manage:comments', page)) {
|
||
return 0
|
||
}
|
||
// -> The address is the fallback for a session with no user on it as well as for a guest: a key
|
||
// ending in `undefined` would be one counter shared by everybody it happened to
|
||
const who = (req.session?.authenticated ? req.session.user?.id : null) ?? `ip:${req.ip}`
|
||
const verdict = await WIKI.models.rateLimits.consume(`comment:${req.params.siteId}:${who}`, {
|
||
/*
|
||
One post per window, and a ban as long as the window. Two attempts inside the cooldown are one
|
||
post and one refusal, and the refusal does not push the ban further out — a banned key stops
|
||
counting, so the wait is measured from the last post that was actually accepted plus whatever
|
||
the client spent retrying.
|
||
*/
|
||
max: 1,
|
||
windowSeconds: seconds,
|
||
banSeconds: seconds
|
||
})
|
||
return verdict.allowed ? 0 : verdict.retryAfter
|
||
}
|
||
|
||
export default routes
|