feat: add page watching (wip) + rate limiting on endpoints

scarlett
NGPixel 1 month ago
parent 1105cf4b5e
commit 14e1efae41
No known key found for this signature in database

@ -1,3 +1,4 @@
import { limitAuthAttempts } from '../helpers/rateLimit.ts'
import type { FastifyInstance } from 'fastify'
/**
@ -141,6 +142,8 @@ async function routes(app: FastifyInstance) {
config: {
publicAccess: true
},
// -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Login',
tags: ['Authentication'],
@ -223,6 +226,8 @@ async function routes(app: FastifyInstance) {
config: {
publicAccess: true
},
// -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Change Password From Login',
tags: ['Authentication'],
@ -317,6 +322,8 @@ async function routes(app: FastifyInstance) {
config: {
publicAccess: true
},
// -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Submit a 2FA Security Code From Login',
description:
@ -405,6 +412,8 @@ async function routes(app: FastifyInstance) {
config: {
publicAccess: true
},
// -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Get the options for logging in with a passkey',
description:
@ -467,6 +476,8 @@ async function routes(app: FastifyInstance) {
config: {
publicAccess: true
},
// -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
onRequest: limitAuthAttempts,
schema: {
summary: 'Login With a Passkey',
description:

@ -45,6 +45,7 @@ async function routes(app: FastifyInstance) {
app.register(import('./tags.ts'))
app.register(import('./tree.ts'))
app.register(import('./users.ts'), { prefix: '/users' })
app.register(import('./watching.ts'))
}
export default routes

@ -3,6 +3,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { PageActor, PageInput } from '../models/pages.ts'
import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts'
import { generatePathHash } from '../helpers/common.ts'
import { limitAuthAttempts } from '../helpers/rateLimit.ts'
/** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */
function splitList(value?: string): string[] {
@ -486,16 +487,23 @@ async function routes(app: FastifyInstance) {
They are answered here from the page already in hand, against rules already in memory, which
is what makes a page view one request instead of four.
*/
const actorId = actor?.id ?? null
const [approvalState, isWatching] = await Promise.all([
WIKI.models.approvals.pageViewerState(req, req.params.siteId, {
id: page.id,
path: page.path,
tags: page.tags ?? [],
allowContributions: page.allowContributions
}),
// -> One indexed lookup on (pageId, userId), and none at all for a reader with no account
WIKI.models.pageWatching.isWatching(page.id, actorId)
])
return {
...page,
viewer: {
permissions: pagePermissionsFor(req, page),
...(await WIKI.models.approvals.pageViewerState(req, req.params.siteId, {
id: page.id,
path: page.path,
tags: page.tags ?? [],
allowContributions: page.allowContributions
}))
...approvalState,
isWatching
}
}
}
@ -511,6 +519,8 @@ async function routes(app: FastifyInstance) {
}>(
'/sites/:siteId/pages/:pageIdOrHash/unlock',
{
// -> A password endpoint like the ones in `api/authentication.ts`, and limited with them
onRequest: limitAuthAttempts,
schema: {
summary: 'Unlock a password-protected page',
description:

@ -228,6 +228,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
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#' },
@ -238,6 +243,32 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
})
/**
* 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
*/

@ -75,6 +75,27 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'boolean',
description: 'Stored, but there is no upload endpoint yet.'
},
authRateLimitEnabled: {
type: 'boolean',
description:
'Whether the authentication endpoints — signing in, second factors, password changes from the login screen, passkey ceremonies and page unlocks — refuse a client that has attempted too often. Counted per client address, in the database, so the limit holds across instances.'
},
authRateLimitMax: {
type: 'integer',
minimum: 1,
description: 'Attempts allowed within the window. The one that exceeds it earns the ban.'
},
authRateLimitWindow: {
type: 'string',
maxLength: 16,
description: 'How long attempts are counted over, as a duration — e.g. `5m`, `2h`, `1d`.'
},
authRateLimitBan: {
type: 'string',
maxLength: 16,
description:
'How long a client is refused for once it goes over, as a duration — e.g. `15m`, `1h`. Attempts made while banned do not extend it.'
},
authJwtAudience: {
type: 'string',
maxLength: 255,

@ -0,0 +1,180 @@
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
/**
* The page being watched, as this requester is allowed to see it.
*
* Watching a page is a thing done TO a page, so it goes through the same gate as reading one: an
* anonymous requester never gets here at all, and a page somebody may not read is answered as though
* it were not there. A password is not part of it the watcher is asking to be told when the page
* changes, not to read what it says.
*/
async function loadWatchablePage(req: FastifyRequest, siteId: string, pageId: string) {
const page = await WIKI.models.pages.getPage({
siteId,
id: pageId,
unlocked: (id: string) => unlockedFor(req, id)
})
if (!page || !mayOnPage(req, 'read:pages', page)) {
return null
}
return page
}
/**
* The user doing the watching, or a refusal.
*
* Watching belongs to an account: it is a list somebody comes back to, and a row has to point at a
* person for a notification to ever have a recipient. There is no permission for it beyond being
* logged in anybody who may read a page may ask to hear about it.
*/
function watcherOf(req: FastifyRequest, reply: FastifyReply): string | null {
const actor = actorFrom(req)
if (!actor) {
reply.unauthorized('Watching a page requires a logged in user.')
return null
}
return actor.id
}
const pageParams = {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
pageId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'pageId']
}
/**
* Page Watching API Routes
*
* Who has asked to be told when a page changes. Nothing is sent yet notifications are not built
* so these keep the list: the bell on a page writes to it, and the inbox reads it back.
*/
async function routes(app: FastifyInstance) {
/**
* WATCH A PAGE
*/
app.put<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/watch',
{
/*
No route-level `permissions`: this is decided per page, by whether the caller may read it
which comes from a group's rules and not from the group-wide list that hook consults.
*/
schema: {
summary: 'Watch a page',
description:
'Records that the caller wants to hear about changes to this page. Watching a page already watched changes nothing and still answers 200, so the button can be pressed twice without it meaning anything different.',
tags: ['Pages'],
params: pageParams,
response: {
200: {
description: 'The page is being watched',
type: 'object',
properties: {
ok: { type: 'boolean' },
isWatching: { type: 'boolean' }
}
}
}
}
},
async (req, reply) => {
const userId = watcherOf(req, reply)
if (!userId) {
return reply
}
const page = await loadWatchablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')
}
await WIKI.models.pageWatching.watch({
siteId: req.params.siteId,
pageId: page.id,
userId
})
return { ok: true, isWatching: true }
}
)
/**
* UNWATCH A PAGE
*/
app.delete<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/watch',
{
// -> Same as above: readable is the test, and it is per page
schema: {
summary: 'Stop watching a page',
description:
'Forgets that the caller wanted to hear about this page. A page that was not being watched answers the same way, since the outcome asked for — no longer watching it — already holds.',
tags: ['Pages'],
params: pageParams,
response: {
200: {
description: 'The page is no longer being watched',
type: 'object',
properties: {
ok: { type: 'boolean' },
isWatching: { type: 'boolean' }
}
}
}
}
},
async (req, reply) => {
const userId = watcherOf(req, reply)
if (!userId) {
return reply
}
/*
The page is NOT loaded first. Unwatching has to keep working for a page that has since been
made unreadable, or the row would be stuck there with nothing in the interface able to remove
it and there is nothing to protect anyway: this only ever deletes the caller's own row.
*/
await WIKI.models.pageWatching.unwatch({ pageId: req.params.pageId, userId })
return { ok: true, isWatching: false }
}
)
/**
* LIST WATCHED PAGES
*/
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/watching',
{
// -> Everything it returns is the caller's own, so being logged in is the whole of the check
schema: {
summary: 'List the pages the caller is watching',
description:
'The watch list of the caller on this site, most recently watched first. Titles and paths come from the pages themselves, so a page that has been renamed or moved is listed where it is now.',
tags: ['Pages'],
params: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' }
},
required: ['siteId']
},
response: {
200: {
description: 'Watched pages',
type: 'array',
items: { $ref: 'WatchedPage#' }
}
}
}
},
async (req, reply) => {
const userId = watcherOf(req, reply)
if (!userId) {
return reply
}
return WIKI.models.pageWatching.listForUser(req.params.siteId, userId)
}
)
}
export default routes

@ -72,6 +72,10 @@ defaults:
forceAssetDownload: true
disallowOpenRedirect: true
enforceSameOriginReferrerPolicy: true
authRateLimitEnabled: true
authRateLimitMax: 10
authRateLimitWindow: '2m'
authRateLimitBan: '15m'
flags:
experimental: false
authDebug: false

@ -0,0 +1,13 @@
CREATE TABLE "pageWatching" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"createdAt" timestamp DEFAULT now() NOT NULL,
"pageId" uuid NOT NULL,
"siteId" uuid NOT NULL,
"userId" uuid NOT NULL
);
--> statement-breakpoint
CREATE INDEX "pageWatching_user_site_idx" ON "pageWatching" ("userId","siteId");--> statement-breakpoint
CREATE UNIQUE INDEX "pageWatching_page_user_idx" ON "pageWatching" ("pageId","userId");--> statement-breakpoint
ALTER TABLE "pageWatching" ADD CONSTRAINT "pageWatching_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "pageWatching" ADD CONSTRAINT "pageWatching_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "pageWatching" ADD CONSTRAINT "pageWatching_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;

@ -0,0 +1,9 @@
CREATE TABLE "rateLimits" (
"key" varchar(255) PRIMARY KEY,
"hits" integer DEFAULT 0 NOT NULL,
"windowStartedAt" timestamp DEFAULT now() NOT NULL,
"bannedUntil" timestamp,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "rateLimits_updatedAt_idx" ON "rateLimits" ("updatedAt");

@ -497,6 +497,68 @@ export const pageEditSubmissions = pgTable(
]
)
// PAGE WATCHING -----------------------
/**
* A page somebody asked to be told about, one row per person per page.
*
* A row IS the watch: there is no `isEnabled` to turn off, because unwatching a page is not a state a
* page keeps it is the absence of interest, and the row goes. Which is also why the whole table can
* be read as "everyone to notify about this page" when notifications are built on top of it.
*
* `siteId` is carried alongside `pageId` rather than reached through the page, since every query here
* is scoped to one site: the watch list belongs to an inbox, and an inbox belongs to a site.
*/
export const pageWatching = pgTable(
'pageWatching',
{
id: uuid().primaryKey().defaultRandom(),
createdAt: timestamp().notNull().defaultNow(),
pageId: uuid()
.notNull()
.references(() => pages.id, { onDelete: 'cascade' }),
siteId: uuid()
.notNull()
.references(() => sites.id),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' })
},
(table) => [
// -> Covers the site scoping too, being the leading column: this is the inbox's own query
index('pageWatching_user_site_idx').on(table.userId, table.siteId),
// -> Watching a page twice is watching it once, so the second attempt is a no-op rather than a row
uniqueIndex('pageWatching_page_user_idx').on(table.pageId, table.userId)
]
)
// RATE LIMITS -------------------------
/**
* One counter per rate-limited client, and the ban it has earned itself.
*
* In the database rather than in each instance's memory because a limit every instance enforces on
* its own is a limit multiplied by however many are running and because a ban has to hold when the
* next attempt lands on another one. Every read and write of a row happens in a single upserting
* statement (`models/rateLimits.ts`), which is what makes concurrent attempts count exactly once.
*
* Rows are self-correcting: an expired window or ban is reset by the next attempt on that key. They
* are only ever deleted to reclaim space see the `purgeRateLimits` task.
*/
export const rateLimits = pgTable(
'rateLimits',
{
/** What is being limited and who by, e.g. `auth:203.0.113.4`. */
key: varchar({ length: 255 }).primaryKey(),
/** Attempts made inside the current window. */
hits: integer().notNull().default(0),
windowStartedAt: timestamp().notNull().defaultNow(),
/** When the ban lifts. Null for a client that has not earned one. */
bannedUntil: timestamp(),
updatedAt: timestamp().notNull().defaultNow()
},
// -> How the purge finds rows nothing has touched in a long while
(table) => [index('rateLimits_updatedAt_idx').on(table.updatedAt)]
)
// SETTINGS ----------------------------
export const settings = pgTable('settings', {
key: varchar({ length: 255 }).notNull().primaryKey(),

@ -11,6 +11,18 @@ export interface Deferred<T = void> {
promise: Promise<T>
}
/** Seconds in each unit a duration setting may be written with. See `durationToSeconds`. */
const DURATION_UNIT_SECONDS = {
s: 1,
m: 60,
h: 3600,
d: 86400,
w: 604800,
y: 31536000
} as const
type DurationUnit = keyof typeof DURATION_UNIT_SECONDS
/* eslint-disable promise/param-names */
export function createDeferred<T = void>(): Deferred<T> {
let result: Promise<T> | undefined
@ -120,6 +132,25 @@ export function generatePathHash(str: string, seed = 0): string {
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16)
}
/**
* How long a duration written the way the admin area writes them lasts, in seconds.
*
* `30s`, `15m`, `2h`, `7d`, `2w`, `1y` one number and one unit, which is the form every duration
* setting takes (the JWT ones included) and the form `DURATION_PATTERN` in `models/security.ts`
* accepts. A year is 365 days and a month is not offered at all: these measure how long something
* lasts, not what date it lands on, so a calendar has no say in it.
*
* @param fallback Returned for anything unparseable, so one bad setting cannot turn a limit off
*/
export function durationToSeconds(value: unknown, fallback: number): number {
const match = /^(\d+)([smhdwy])$/.exec(String(value ?? '').trim())
if (!match) {
return fallback
}
const seconds = Number(match[1]) * DURATION_UNIT_SECONDS[match[2] as DurationUnit]
return seconds > 0 ? seconds : fallback
}
/**
* Get default value of type
*

@ -0,0 +1,71 @@
import { durationToSeconds } from './common.ts'
import type { FastifyReply, FastifyRequest } from 'fastify'
import type { RateLimitPolicy } from '../models/rateLimits.ts'
/**
* Defaults for the limit on the authentication endpoints, used until an administrator saves their own
* and whenever a stored value is missing or unusable. Ten attempts in five minutes is far more than a
* person signing in needs and far less than guessing a password takes.
*/
const AUTH_DEFAULTS: RateLimitPolicy = {
max: 10,
windowSeconds: 300,
banSeconds: 900
}
/**
* The configured policy.
*
* Every field falls back on its own, so one unusable value leaves the rest of the limit standing
* rather than turning it off which is the failure mode worth avoiding here. The two durations are
* stored as an operator wrote them (`5m`, `15m`, `1d`), the way the JWT settings beside them are.
*/
function authPolicy(): RateLimitPolicy {
const security = WIKI.config.security ?? {}
const max = Number(security.authRateLimitMax)
return {
max: Number.isFinite(max) && max > 0 ? Math.floor(max) : AUTH_DEFAULTS.max,
windowSeconds: durationToSeconds(security.authRateLimitWindow, AUTH_DEFAULTS.windowSeconds),
banSeconds: durationToSeconds(security.authRateLimitBan, AUTH_DEFAULTS.banSeconds)
}
}
/**
* Refuse an attempt at an authentication endpoint once a client has made too many.
*
* Written as a per-route `onRequest` hook `{ onRequest: limitAuthAttempts, schema: … }` so that it
* runs before the body is even parsed, and so that the routes it guards say so where they are declared
* rather than in a list somewhere else. The endpoints that carry it are the ones where the request
* IS the guess: signing in, answering a second factor, changing a password from the login screen,
* a passkey ceremony, and unlocking a page.
*
* One counter per client address, shared by all of them: an attacker working through passwords on two
* of these endpoints is one attacker, and splitting the count per endpoint would let them have the
* limit twice over. `req.ip` is what the client is identified by, which behind a proxy means the
* `trustProxy` security setting has to be on for this to see anything but the proxy.
*
* Attempts are counted whether or not they succeed. A limit on failures only would leave the endpoint
* open to being hammered with valid credentials, and the numbers are set for a person signing in, who
* does not come close to them.
*/
export async function limitAuthAttempts(req: FastifyRequest, reply: FastifyReply): Promise<void> {
if (WIKI.config.security?.authRateLimitEnabled === false) {
return
}
const verdict = await WIKI.models.rateLimits.consume(`auth:${req.ip}`, authPolicy())
if (verdict.allowed) {
return
}
WIKI.models.flags.authDebug(
`Rate limit: refused ${req.method} ${req.url} from ${req.ip}, ${verdict.retryAfter}s left of its ban.`
)
/*
429 rather than 403, and with `Retry-After`: this is not a refusal to serve the client, it is the
same answer as before with a time on it which is what a legitimate user locked out by a shared
address needs to be told.
*/
reply.header('Retry-After', String(verdict.retryAfter))
return reply.tooManyRequests(
`Too many attempts. Try again in ${Math.ceil(verdict.retryAfter / 60)} minute(s).`
)
}

@ -717,6 +717,7 @@
"admin.security.disallowIframeHint": "Prevents other websites from embedding your wiki in an iframe. This provides clickjacking protection.",
"admin.security.disallowOpenRedirect": "Block Open Redirect",
"admin.security.disallowOpenRedirectHint": "Prevents user controlled URLs from directing to websites outside of your wiki. This provides Open Redirect protection.",
"admin.security.durationPlaceholder": "e.g. 15m, 2h, 1d",
"admin.security.enforce2fa": "Enforce 2FA",
"admin.security.enforce2faHint": "Force all users to use Two-Factor Authentication when using an authentication provider with a user / password form.",
"admin.security.enforceHsts": "Enforce HSTS",
@ -740,6 +741,18 @@
"admin.security.maxUploadSizeHint": "The maximum size for a single file. Final value in base 2.",
"admin.security.maxUploadSizeInvalid": "The maximum upload size must be a positive value, e.g. 10 MB.",
"admin.security.maxUploadSizeSuffix": "bytes",
"admin.security.rateLimit": "Rate Limiting",
"admin.security.rateLimitBan": "Ban Duration",
"admin.security.rateLimitBanHint": "How long a client is refused for once it goes over, as a duration. Attempts made while banned do not extend it.",
"admin.security.rateLimitEnabled": "Limit Authentication Attempts",
"admin.security.rateLimitEnabledHint": "Refuse repeated attempts to sign in, answer a second factor, change a password from the login screen, use a passkey or unlock a page. Counted per client address, in the database, so the limit holds across instances.",
"admin.security.rateLimitMax": "Max Attempts",
"admin.security.rateLimitMaxHint": "How many attempts a client may make within the time window below.",
"admin.security.rateLimitMaxSuffix": "attempts",
"admin.security.rateLimitProxyWarn": "Behind a reverse proxy, turn on \"Trust X-Forwarded-* Proxy Headers\" above — without it every visitor looks like the proxy and shares one limit.",
"admin.security.rateLimitRecommended": "Keeping rate limiting enabled is highly recommended: without it, passwords, second factors and page passwords can be guessed as fast as the server will answer.",
"admin.security.rateLimitWindow": "Time Window",
"admin.security.rateLimitWindowHint": "How long attempts are counted over, as a duration — 30s, 5m, 2h, 1d. Going over the limit within it earns a ban.",
"admin.security.restartRequired": "Header, CORS and proxy settings are applied when the server starts, so they take effect after a restart.",
"admin.security.saveFailed": "Failed to save the security configuration.",
"admin.security.saveSuccess": "Security configuration updated successfully.",
@ -1575,9 +1588,13 @@
"common.page.unlockTitle": "Unlock this page",
"common.page.unpublished": "Unpublished",
"common.page.unpublishedWarning": "This page is not published.",
"common.page.unwatch": "Stop Watching",
"common.page.unwatchFailed": "Could not stop watching this page.",
"common.page.versionId": "Version ID {id}",
"common.page.viewingSource": "Viewing source of page {path}",
"common.page.viewingSourceVersion": "Viewing source as of {date} of page {path}",
"common.page.watch": "Watch Page",
"common.page.watchFailed": "Could not watch this page.",
"common.pageSelector.createTitle": "Select New Page Location",
"common.pageSelector.folderEmptyWarning": "This folder is empty.",
"common.pageSelector.moveTitle": "Move / Rename Page Location",
@ -2063,7 +2080,15 @@
"inbox.reviewViewPage": "View Page",
"inbox.title": "Inbox & Notifications",
"inbox.watching": "Watching",
"inbox.watchingInfo": "Nothing here yet.",
"inbox.watchingHint": "Open a page and press the bell in its header to start watching it.",
"inbox.watchingInfo": "Pages you asked to be told about, most recently added first.",
"inbox.watchingLoadFailed": "Failed to load your watched pages.",
"inbox.watchingNone": "You are not watching any page yet.",
"inbox.watchingSince": "Watching since {date}",
"inbox.watchingUnwatch": "Stop Watching",
"inbox.watchingUnwatchFailed": "Could not stop watching this page.",
"inbox.watchingUnwatched": "You are no longer watching {title}.",
"inbox.watchingUpdated": "Last modified on {date}",
"linkPicker.emptyFolder": "There are no pages in this folder.",
"linkPicker.linkUrl": "Link URL",
"linkPicker.loadFailed": "Failed to load the page tree.",

@ -13,7 +13,9 @@ import { locales } from './locales.ts'
import { navigation } from './navigation.ts'
import { pageHistory } from './pageHistory.ts'
import { pages } from './pages.ts'
import { pageWatching } from './pageWatching.ts'
import { passkeys } from './passkeys.ts'
import { rateLimits } from './rateLimits.ts'
import { rendering } from './rendering.ts'
import { search } from './search.ts'
import { security } from './security.ts'
@ -41,7 +43,9 @@ export default {
navigation,
pageHistory,
pages,
pageWatching,
passkeys,
rateLimits,
rendering,
search,
security,

@ -46,6 +46,11 @@ class Jobs {
// cron: '0 */6 * * *',
// type: 'system'
// },
{
task: 'purgeRateLimits',
cron: '10 * * * *',
type: 'system'
},
{
task: 'updateLocales',
cron: '0 0 * * *',

@ -0,0 +1,102 @@
import { and, desc, eq } from 'drizzle-orm'
import { pageWatching as watchingTable, pages as pagesTable } from '../db/schema.ts'
/** A watched page, as the inbox lists it. */
export interface WatchedPage {
pageId: string
path: string
locale: string
title: string
description: string | null
icon: string | null
/** When the page itself last changed, which is what a watcher is watching FOR. */
updatedAt: Date
/** When this person started watching, i.e. how long they have been asking to be told. */
watchedAt: Date
}
/**
* Page watching model
*
* Who has asked to be told about which pages. Nothing is sent yet notifications are not built so
* this is the list and nothing more: the bell on a page reads it, the inbox lists it, and whatever
* delivers the news later has one place to ask who wants it.
*/
class PageWatching {
/**
* Whether this user is watching this page.
*
* Answered for the page view, which asks about every page it draws, so it is a single indexed lookup
* on the pair and not asked at all for a guest, who cannot watch anything.
*/
async isWatching(pageId: string, userId: string | null): Promise<boolean> {
if (!userId) {
return false
}
const rows = await WIKI.db
.select({ id: watchingTable.id })
.from(watchingTable)
.where(and(eq(watchingTable.pageId, pageId), eq(watchingTable.userId, userId)))
.limit(1)
return rows.length > 0
}
/**
* Start watching a page.
*
* Idempotent: watching a page one is already watching is what the reader asked for, and the unique
* index turns the second row into nothing rather than into an error.
*/
async watch({
siteId,
pageId,
userId
}: {
siteId: string
pageId: string
userId: string
}): Promise<void> {
await WIKI.db
.insert(watchingTable)
.values({ siteId, pageId, userId })
.onConflictDoNothing({ target: [watchingTable.pageId, watchingTable.userId] })
}
/**
* Stop watching a page. Also idempotent, for the same reason: the outcome asked for is that no row
* exists, and it does not.
*/
async unwatch({ pageId, userId }: { pageId: string; userId: string }): Promise<void> {
await WIKI.db
.delete(watchingTable)
.where(and(eq(watchingTable.pageId, pageId), eq(watchingTable.userId, userId)))
}
/**
* The pages this user watches on a site, most recently watched first.
*
* Joined to the pages rather than storing a copy of the title and the path, so a page that is
* renamed or moved is listed where it is now which is the point of watching it. A deleted page
* takes its rows with it through the foreign key, so nothing here can point at one that is gone.
*/
async listForUser(siteId: string, userId: string): Promise<WatchedPage[]> {
const rows = await WIKI.db
.select({
pageId: pagesTable.id,
path: pagesTable.path,
locale: pagesTable.locale,
title: pagesTable.title,
description: pagesTable.description,
icon: pagesTable.icon,
updatedAt: pagesTable.updatedAt,
watchedAt: watchingTable.createdAt
})
.from(watchingTable)
.innerJoin(pagesTable, eq(pagesTable.id, watchingTable.pageId))
.where(and(eq(watchingTable.userId, userId), eq(watchingTable.siteId, siteId)))
.orderBy(desc(watchingTable.createdAt))
return rows as WatchedPage[]
}
}
export const pageWatching = new PageWatching()

@ -0,0 +1,121 @@
import { lt, sql } from 'drizzle-orm'
import { rateLimits as rateLimitsTable } from '../db/schema.ts'
/** How a limit is configured: what it allows, over how long, and what it costs to exceed. */
export interface RateLimitPolicy {
/** Attempts allowed within the window. The one that exceeds it is what earns the ban. */
max: number
/** Length of the window, in seconds. */
windowSeconds: number
/** How long the ban lasts once earned, in seconds. */
banSeconds: number
}
/** What the caller does about an attempt. */
export interface RateLimitVerdict {
/** Whether the attempt may proceed. */
allowed: boolean
/** Attempts made in the current window, the one just counted included. */
hits: number
/** Seconds until the client may try again. Zero while it is still allowed. */
retryAfter: number
}
/**
* Rate limits model
*
* A fixed window per key, with a ban for going over it the counter that stands behind the login and
* the other endpoints where guessing is the attack (`helpers/rateLimit.ts` is what applies it).
*
* Every attempt is one statement, and the statement decides everything: whether the window has rolled
* over, whether the ban has lifted, what the count now is, and whether this attempt has just earned a
* ban. Doing it in the database rather than in the process is the whole point two instances behind
* a load balancer share one counter, and a ban issued by one is honoured by both.
*/
class RateLimits {
/**
* Count an attempt against a key, and say whether it may proceed.
*
* The row is read, rolled over, incremented and possibly banned in a single upsert, so concurrent
* attempts cannot both read the same count and both decide they are under the limit. What comes
* back is the row as it now stands.
*
* A banned key stops counting: its ban runs for exactly as long as it was set for, rather than
* being pushed further out by every attempt made during it. When the ban lifts, the window starts
* again from nothing having served it, a client is not one attempt away from serving another.
*
* @param key What is being limited and who by, e.g. `auth:203.0.113.4`
*/
async consume(key: string, policy: RateLimitPolicy): Promise<RateLimitVerdict> {
const window = sql`make_interval(secs => ${policy.windowSeconds})`
/*
The three cases, in the order the CASE arms decide them:
1. still banned -- nothing changes; the attempt is refused
2. window rolled over, or a ban has just expired -- the row starts again at this attempt
3. otherwise -- one more attempt, and a ban if that is one too many
`"rateLimits"` rather than the aliased `excluded`: these have to read the row AS IT WAS, and
`excluded` is the row this statement proposed.
*/
const rolledOver = sql`"rateLimits"."bannedUntil" is not null or "rateLimits"."windowStartedAt" <= now() - ${window}`
const stillBanned = sql`"rateLimits"."bannedUntil" > now()`
const rows = await WIKI.db.execute(sql`
insert into "rateLimits" ("key", "hits", "windowStartedAt", "updatedAt")
values (${key}, 1, now(), now())
on conflict ("key") do update set
"hits" = case
when ${stillBanned} then "rateLimits"."hits"
when ${rolledOver} then 1
else "rateLimits"."hits" + 1
end,
"windowStartedAt" = case
when ${stillBanned} then "rateLimits"."windowStartedAt"
when ${rolledOver} then now()
else "rateLimits"."windowStartedAt"
end,
"bannedUntil" = case
when ${stillBanned} then "rateLimits"."bannedUntil"
when ${rolledOver} then null
when "rateLimits"."hits" + 1 > ${policy.max} then now() + make_interval(secs => ${policy.banSeconds})
else null
end,
"updatedAt" = now()
returning
"hits",
"bannedUntil" > now() as "isBanned",
greatest(0, ceil(extract(epoch from coalesce("bannedUntil", now()) - now())))::int as "retryAfter"
`)
const row = (rows as any).rows?.[0] ?? (rows as any)[0]
return {
allowed: !row?.isBanned,
hits: Number(row?.hits ?? 0),
retryAfter: row?.isBanned ? Number(row.retryAfter) : 0
}
}
/**
* Forget a key, e.g. once the attempt it was counting has succeeded.
*/
async reset(key: string): Promise<void> {
await WIKI.db.delete(rateLimitsTable).where(sql`${rateLimitsTable.key} = ${key}`)
}
/**
* Drop rows nothing has touched for a day.
*
* Only ever about reclaiming space: a stale row is already harmless, since the next attempt on that
* key rolls its window over before reading it. A key nobody has used in a day is one nobody is
* being limited by.
*
* @returns How many rows were dropped
*/
async purgeStale(): Promise<number> {
const result = await WIKI.db
.delete(rateLimitsTable)
.where(lt(rateLimitsTable.updatedAt, sql`now() - interval '1 day'`))
return result.rowCount ?? 0
}
}
export const rateLimits = new RateLimits()

@ -2,6 +2,10 @@ import { CORS_MODES, parseCspDirectives } from '../helpers/security.ts'
/** Fields stored in the `security` settings blob. */
export const SECURITY_FIELDS = [
'authRateLimitBan',
'authRateLimitEnabled',
'authRateLimitMax',
'authRateLimitWindow',
'corsConfig',
'corsMode',
'cspDirectives',
@ -112,6 +116,20 @@ class Security {
return 'Enforcing HSTS needs a duration greater than zero.'
}
if (merged.authRateLimitEnabled) {
if (!(merged.authRateLimitMax > 0)) {
return 'The attempt limit must be greater than zero.'
}
for (const [field, label] of [
['authRateLimitWindow', 'time window'],
['authRateLimitBan', 'ban duration']
] as const) {
if (!DURATION_PATTERN.test(`${merged[field] ?? ''}`.trim())) {
return `The ${label} must be a duration such as 30s, 15m, 2h or 1d.`
}
}
}
for (const [field, label] of [
['authJwtExpiration', 'token expiration'],
['authJwtRenewablePeriod', 'token renewal period']

@ -0,0 +1,13 @@
export async function task(): Promise<void> {
WIKI.logger.info('Purging stale rate limit counters...')
try {
const purged = await WIKI.models.rateLimits.purgeStale()
WIKI.logger.info(`Purged ${purged} stale rate limit counters: [ COMPLETED ]`)
} catch (err: any) {
WIKI.logger.error('Purging stale rate limit counters: [ FAILED ]')
WIKI.logger.error(err.message)
throw err
}
}

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
257 icons.
259 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -22,7 +22,6 @@ export const BUNDLED_ICONS = {
"la:bell": {"body":"<path fill=\"currentColor\" d=\"M16 3a2 2 0 0 0-2 2c0 .086.02.168.031.25C10.574 6.133 8 9.273 8 13v9c0 .566-.434 1-1 1H6v2h7.188A3 3 0 0 0 13 26c0 1.645 1.355 3 3 3s3-1.355 3-3a3 3 0 0 0-.188-1H26v-2h-1c-.566 0-1-.434-1-1v-8.719c0-3.758-2.512-7.11-6.031-8.031c.011-.082.031-.164.031-.25a2 2 0 0 0-2-2m-.438 4c.145-.012.29 0 .438 0h.188C19.453 7.098 22 9.96 22 13.281V22c0 .352.074.684.188 1H9.813A3 3 0 0 0 10 22v-9a6.005 6.005 0 0 1 5.563-6zM16 25c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1\"/>","width":32,"height":32},
"la:bolt": {"body":"<path fill=\"currentColor\" d=\"M16 6v9l4 1l-3.898 10H16v-8.031l-4-1L15.898 6zm2-2h-3.512l-.472 1.328l-3.903 10.973l-.734 2.074l2.137.535l2.484.621V28h3.469l.496-1.273l3.898-10l.825-2.118L18 13.438z\"/>","width":32,"height":32},
"la:book": {"body":"<path fill=\"currentColor\" d=\"M9 4C7.355 4 6 5.355 6 7v18c0 1.645 1.355 3 3 3h17V4zm0 2h15v16H9a3 3 0 0 0-1 .188V7c0-.566.434-1 1-1m2 3v2h11V9zM9 24h15v2H9c-.566 0-1-.434-1-1s.434-1 1-1\"/>","width":32,"height":32},
"la:bookmark": {"body":"<path fill=\"currentColor\" d=\"M7 5v23l1.594-1.188L16 21.25l7.406 5.563L25 28V5zm2 2h14v17l-6.406-4.813L16 18.75l-.594.438L9 24z\"/>","width":32,"height":32},
"la:broadcast-tower": {"body":"<path fill=\"currentColor\" d=\"M7.188 4.188c-4.297 4.183-4.282 11.125 0 15.406l1.406-1.407c-3.52-3.519-3.504-9.148 0-12.562zm17.625.093L23.405 5.72c3.524 3.523 3.524 9.039 0 12.562l1.407 1.438a10.897 10.897 0 0 0 0-15.438zM9.905 7.188c-2.586 2.585-2.586 6.82 0 9.406l1.406-1.407a4.68 4.68 0 0 1 0-6.593zm12.188.093L20.687 8.72a4.64 4.64 0 0 1 0 6.562l1.407 1.438c2.586-2.586 2.586-6.852 0-9.438zM16 10a2 2 0 0 0-2 2c0 .625.3 1.164.75 1.531L10.312 26H9v2h4v-2h-.594L16 15.969L19.594 26H19v2h4v-2h-1.313L17.25 13.531c.45-.367.75-.906.75-1.531a2 2 0 0 0-2-2\"/>","width":32,"height":32},
"la:broom": {"body":"<path fill=\"currentColor\" d=\"m28.281 2.281l-10 10L17 11v-.031l-.031-.031c-.64-.57-1.477-.844-2.282-.844s-1.582.3-2.187.906l-.156.125l-.5.5l-.344.281L2.375 19l-.875.719L12.281 30.5l.719-.875l7.063-9.063l.03.032l1-1h.032l.031-.032c1.14-1.285 1.149-3.257-.062-4.468l-1.375-1.375l10-10zm-13.593 9.813a1.4 1.4 0 0 1 .906.312c.011.008.02.024.031.031l4.063 4.063c.375.375.41 1.172 0 1.688c-.016.019-.016.042-.032.062l-.312.281l-5.782-5.781l.344-.344c.192-.191.473-.304.781-.312zM12.03 14.03l5.938 5.938l-5.875 7.5l-1.438-1.438l2.156-2.25l-1.437-1.375l-2.125 2.219l-1.313-1.313l3.875-3.906L10.406 18L6.5 21.875l-1.969-1.969z\"/>","width":32,"height":32},
"la:calendar": {"body":"<path fill=\"currentColor\" d=\"M9 4v1H5v22h22V5h-4V4h-2v1H11V4zM7 7h2v1h2V7h10v1h2V7h2v2H7zm0 4h18v14H7zm6 2v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 17v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 21v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2z\"/>","width":32,"height":32},
@ -154,6 +153,9 @@ export const BUNDLED_ICONS = {
"mdi:alpha-t-box-outline": {"body":"<path fill=\"currentColor\" d=\"M9 7h6v2h-2v8h-2V9H9zM5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m0 2v14h14V5z\"/>","width":24,"height":24},
"mdi:arrow-vertical-lock": {"body":"<path fill=\"currentColor\" d=\"M18.8 11V9.5C18.8 8.1 17.4 7 16 7s-2.8 1.1-2.8 2.5V11c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3m-1.3 0h-3V9.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3zM9 6h3L8 2L4 6h3v12H4l4 4l4-4H9z\"/>","width":24,"height":24},
"mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24},
"mdi:bell": {"body":"<path fill=\"currentColor\" d=\"M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2\"/>","width":24,"height":24},
"mdi:bell-off-outline": {"body":"<path fill=\"currentColor\" d=\"M22.11 21.46L2.39 1.73L1.11 3l4.72 4.72A7 7 0 0 0 5 11v6l-2 2v1h15.11l2.73 2.73zM7 18v-7c0-.61.11-1.21.34-1.77L16.11 18zm3 3h4a2 2 0 0 1-2 2a2 2 0 0 1-2-2M8.29 5.09c.53-.34 1.11-.59 1.71-.8V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v4.8l-2-2V11a5 5 0 0 0-5-5c-.78 0-1.55.2-2.24.56z\"/>","width":24,"height":24},
"mdi:bell-outline": {"body":"<path fill=\"currentColor\" d=\"M10 21h4c0 1.1-.9 2-2 2s-2-.9-2-2m11-2v1H3v-1l2-2v-6c0-3.1 2-5.8 5-6.7V4c0-1.1.9-2 2-2s2 .9 2 2v.3c3 .9 5 3.6 5 6.7v6zm-4-8c0-2.8-2.2-5-5-5s-5 2.2-5 5v7h10z\"/>","width":24,"height":24},
"mdi:book-plus": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 1.1.3 2.12.81 3H6c-1.11 0-2-.89-2-2V4a2 2 0 0 1 2-2h1v7l2.5-1.5L12 9V2h6a2 2 0 0 1 2 2v9.09c-.33-.05-.66-.09-1-.09c-3.31 0-6 2.69-6 6m7-1v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24},
"mdi:car": {"body":"<path fill=\"currentColor\" d=\"m5 11l1.5-4.5h11L19 11m-1.5 5a1.5 1.5 0 0 1-1.5-1.5a1.5 1.5 0 0 1 1.5-1.5a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-11 0A1.5 1.5 0 0 1 5 14.5A1.5 1.5 0 0 1 6.5 13A1.5 1.5 0 0 1 8 14.5A1.5 1.5 0 0 1 6.5 16M18.92 6c-.2-.58-.76-1-1.42-1h-11c-.66 0-1.22.42-1.42 1L3 12v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1h12v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-8z\"/>","width":24,"height":24},
"mdi:check": {"body":"<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>","width":24,"height":24},

@ -22,78 +22,6 @@
@click="togglePageProperties">
<w-tooltip anchor="center left" self="center right">Page Properties</w-tooltip>
</w-btn>
</template>
<!--
Between the two halves of the authoring group on purpose: it sits under the Edit button for
somebody who has one, and at the top of the rail for a reviewer who may not edit at all -- which
is why it is outside that group rather than in it.
Only for whoever reviews this page: the server answers `canReview` from the approval rules and
the reviewer's own permissions -- with the page itself -- so nothing here has to know how that
is decided, or ask about it.
-->
<w-btn
class="h-12"
v-if="canReview"
flat
:color="editorStore.isActive ? `white` : `deep-orange-9`"
aria-label="Pending Edit Suggestions">
<!--
The badge is a sibling of the icon, not a child of it: WIcon renders a bare `<svg>` and no
slot, so anything written inside it is dropped -- and an HTML badge could not live inside an
SVG in any case. It floats against the button, which is the positioned box here.
-->
<w-icon name="la:inbox" />
<!--
The same expression as the button's own colour, so the badge cannot drift from the icon it
sits on: `deep-orange-9` on the resting rail, and inverted in the editor, where the rail is
already that orange and an orange badge would disappear into it.
-->
<w-badge
v-if="pendingCount > 0"
:color="editorStore.isActive ? `white` : `deep-orange-9`"
:text-color="editorStore.isActive ? `deep-orange-9` : `white`"
rounded
floating>
<strong>{{ pendingCount }}</strong>
</w-badge>
<w-tooltip anchor="center left" self="center right">
{{ t('inbox.pendingReview') }}
</w-tooltip>
<w-menu
class="translucent-menu"
anchor="top left"
self="top right"
auto-close
transition-show="jump-left">
<w-list padding style="min-width: 320px">
<w-item v-if="pendingCount < 1">
<w-item-section>
<w-item-label caption>{{ t('inbox.reviewNone') }}</w-item-label>
</w-item-section>
</w-item>
<w-item
v-for="submission of pageStore.pendingSubmissions"
:key="submission.id"
clickable
@click="reviewSubmission(submission)">
<w-item-section class="items-center" avatar>
<w-icon class="text-deep-orange-9" name="la:file-alt" size="sm" />
</w-item-section>
<w-item-section>
<w-item-label>
{{ submission.author.name || t('inbox.reviewUnknownAuthor') }}
</w-item-label>
<w-item-label caption>{{ humanizeDate(submission.createdAt) }}</w-item-label>
</w-item-section>
<w-item-section side v-if="submission.isStale">
<w-badge color="warning" rounded>{{ t('inbox.reviewStale') }}</w-badge>
</w-item-section>
</w-item>
</w-list>
</w-menu>
</w-btn>
<template v-if="userStore.can(`write:pages`)">
<w-btn
class="h-12"
v-if="flagsStore.experimental"
@ -314,34 +242,8 @@ const menuPendingAssets = ref(null)
const hasPendingAssets = computed(() => editorStore.pendingAssets?.length > 0)
/*
Both from the page itself: the page route answers who reviews it and what is waiting on it, along
with everything else this rail is drawn from. The rail asked for them separately until it turned out
to be a third request about a page the view had already been given.
*/
const canReview = computed(() => pageStore.canReview)
const pendingCount = computed(() => pageStore.pendingSubmissions.length)
// METHODS
function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})
}
/**
* Open one for review, remembering where it was opened from.
*
* `from=page` is what sends the reviewer back here when they are done rather than to the inbox queue
* they never came through.
*/
function reviewSubmission(submission) {
router.push({ path: `/_inbox/review/${submission.id}`, query: { from: 'page' } })
}
function togglePageProperties() {
siteStore.$patch({
sideDialogComponent: 'PagePropertiesDialog',

@ -80,27 +80,30 @@
class="uppercase"
color="negative"
:label="t(`editor.props.draft`)" />
<!--
Watching a page is a state of it, so the button IS the state: filled and orange while the
page is watched, an outline in grey while it is not. Same orange as Edit, because both are
this reader's own hold on the page rather than decoration.
`mdi` rather than the `la` this row otherwise uses, because Line Awesome has no filled bell
to switch TO: its bell and its bell-solid carry an identical body in Iconify, so that pair
drew one drawing in two colours. (Names left unquoted on purpose the icon bundler scans
this file for quoted references and would keep bundling an icon nothing draws.)
-->
<w-btn
class="ml-4"
:class="{ 'is-ringing': state.bellRinging }"
v-if="userStore.authenticated"
flat
dense
icon="la:bell"
color="grey"
aria-label="Watch Page"
@click="notImplemented">
<w-tooltip>Watch Page</w-tooltip>
</w-btn>
<w-btn
class="ml-4"
v-if="userStore.authenticated"
flat
dense
icon="la:bookmark"
color="grey"
aria-label="Bookmark Page"
@click="notImplemented">
<w-tooltip>Bookmark Page</w-tooltip>
:icon="pageStore.isWatching ? `mdi:bell` : `mdi:bell-outline`"
:color="pageStore.isWatching ? `deep-orange-9` : `grey`"
:aria-label="pageStore.isWatching ? t(`common.page.unwatch`) : t(`common.page.watch`)"
:aria-pressed="pageStore.isWatching"
@click="toggleWatch">
<w-tooltip>
{{ pageStore.isWatching ? t('common.page.unwatch') : t('common.page.watch') }}
</w-tooltip>
</w-btn>
<w-btn
class="ml-4"
@ -113,6 +116,70 @@
@click="printPage">
<w-tooltip>Print</w-tooltip>
</w-btn>
<!--
Only for whoever reviews this page: the server answers `canReview` from the approval rules
and the reviewer's own permissions with the page itself so nothing here has to know how
that is decided, or ask about it.
An empty queue is grey and an empty tray, sitting with Print as one more thing available
rather than one more thing to do; something waiting fills the tray and turns it orange, the
colour this row uses for what belongs to the reader. The count is on the badge either way.
-->
<w-btn
class="ml-4"
v-if="pageStore.canReview"
flat
dense
:color="pendingCount > 0 ? `deep-orange-9` : `grey`"
:aria-label="t(`inbox.pendingReview`)">
<!--
The badge is a sibling of the icon, not a child of it: WIcon renders a bare `<svg>` and no
slot, so anything written inside it is dropped and an HTML badge could not live inside an
SVG in any case. It floats against the button, which is the positioned box here.
-->
<w-icon :name="pendingCount > 0 ? `mdi:inbox-full` : `la:inbox`" />
<w-badge
v-if="pendingCount > 0"
color="deep-orange-9"
text-color="white"
rounded
floating>
<strong>{{ pendingCount }}</strong>
</w-badge>
<w-tooltip>{{ t('inbox.pendingReview') }}</w-tooltip>
<!--
Down from the button's right edge, like every other menu hanging off this row: the panel is
wider than the button and the button is near the right of the window, so aligning their
RIGHT edges is what keeps it on screen.
-->
<w-menu class="translucent-menu" anchor="bottom right" self="top right" auto-close>
<w-list padding style="min-width: 320px">
<w-item v-if="pendingCount < 1">
<w-item-section>
<w-item-label caption>{{ t('inbox.reviewNone') }}</w-item-label>
</w-item-section>
</w-item>
<w-item
v-for="submission of pageStore.pendingSubmissions"
:key="submission.id"
clickable
@click="reviewSubmission(submission)">
<w-item-section class="items-center" avatar>
<w-icon class="text-deep-orange-9" name="la:file-alt" size="sm" />
</w-item-section>
<w-item-section>
<w-item-label>
{{ submission.author.name || t('inbox.reviewUnknownAuthor') }}
</w-item-label>
<w-item-label caption>{{ humanizeDate(submission.createdAt) }}</w-item-label>
</w-item-section>
<w-item-section side v-if="submission.isStale">
<w-badge color="warning" rounded>{{ t('inbox.reviewStale') }}</w-badge>
</w-item-section>
</w-item>
</w-list>
</w-menu>
</w-btn>
</template>
<template v-if="editorStore.isActive">
<!--
@ -264,6 +331,12 @@ import { useUserStore } from '@/stores/user'
import CollabPresence from '@/components/CollabPresence.vue'
import IconPickerDialog from '@/components/IconPickerDialog.vue'
/**
* How long the bell swings for, in milliseconds. Matches the `w-bell-ring` animation below the class
* has to come off once it has played, or the next watch would not play it again.
*/
const BELL_RING_MS = 700
// STORES
const editorStore = useEditorStore()
@ -295,6 +368,20 @@ const isSuggesting = computed(() => editorStore.isActive && editorStore.mode ===
*/
const isEditing = computed(() => editorStore.isActive && !isSuggesting.value)
/** How many suggestions are waiting on this page, which is what the review badge counts. */
const pendingCount = computed(() => pageStore.pendingSubmissions.length)
// DATA
const state = reactive({
/**
* Whether the bell is mid-swing. Set for as long as the animation runs and cleared afterwards, so
* that watching a page again a minute later rings it again a class left on plays once and never
* plays a second time.
*/
bellRinging: false
})
// REFS
/** The two in-place fields, which only exist while the page itself is being edited. */
@ -623,6 +710,49 @@ function printPage() {
window.print()
}
function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})
}
/**
* Open one suggestion for review, remembering where it was opened from.
*
* `from=page` is what sends the reviewer back here when they are done, rather than to the inbox queue
* they never came through.
*/
function reviewSubmission(submission) {
router.push({ path: `/_inbox/review/${submission.id}`, query: { from: 'page' } })
}
/**
* Watch the page, or stop watching it.
*
* The bell rings on the way IN only: a swing is the page announcing that it will now tell you about
* itself, and playing the same flourish for switching that off would say the opposite thing with the
* same gesture. The store moves before the request answers, so the icon flips under the pointer.
*/
async function toggleWatch() {
const watching = !pageStore.isWatching
if (watching) {
state.bellRinging = true
setTimeout(() => {
state.bellRinging = false
}, BELL_RING_MS)
}
try {
await pageStore.pageWatch(watching)
} catch (err) {
notify({
type: 'negative',
message: t(watching ? 'common.page.watchFailed' : 'common.page.unwatchFailed'),
caption: err.message
})
}
}
function notImplemented() {
notify({
type: 'negative',
@ -632,6 +762,52 @@ function notImplemented() {
</script>
<style scoped lang="scss">
/*
The bell swinging as a page starts being watched.
On the icon inside the button rather than on the button itself, so the ripple, the hover tint and the
hit area all stay where they are while only the drawing moves. `transform-origin` at the top centre
is what makes it swing from its mounting instead of spinning about its middle.
`:deep`, because the icon is rendered by `WBtn` and a scoped rule would not reach into it.
*/
.is-ringing :deep(svg) {
animation: w-bell-ring 0.7s ease-in-out;
transform-origin: top center;
}
@keyframes w-bell-ring {
0% {
transform: rotate(0);
}
15% {
transform: rotate(18deg);
}
30% {
transform: rotate(-14deg);
}
45% {
transform: rotate(10deg);
}
60% {
transform: rotate(-7deg);
}
75% {
transform: rotate(4deg);
}
100% {
transform: rotate(0);
}
}
/* -> A swinging bell says nothing the colour change does not; for a reader who asked for less motion
it is noise with a vestibular cost, so it simply does not swing. */
@media (prefers-reduced-motion: reduce) {
.is-ringing :deep(svg) {
animation: none;
}
}
/*
The two headings, while they are also the fields.

@ -32,17 +32,6 @@
{{ t('common.sidebar.browse') }}
</w-tooltip>
</w-btn>
<!-- -> Nothing to divide from Bookmarks when neither button above it renders -->
<w-separator v-if="siteStore.locales.showMenu || canBrowse" class="my-2" inset dark />
<w-btn
class="py-4"
flat
icon="la:bookmark"
color="white"
aria-label="Bookmarks"
@click="notImplemented">
<w-tooltip anchor="center right" self="center left">Bookmarks</w-tooltip>
</w-btn>
<w-space />
<w-btn
v-if="canEditNav"
@ -90,18 +79,15 @@
</w-btn>
</div>
<nav-sidebar />
<w-bar v-if="userStore.authenticated" class="sidebar-footerbtns text-white" dense>
<template v-if="canEditNav">
<w-btn class="flex-1" icon="la:dharmachakra" label="Edit Nav" flat>
<w-menu ref="navEditMenu" anchor="top left" self="bottom left" :offset="[0, 10]">
<nav-edit-menu
:menu-hide-handler="navEditMenu.hide"
:update-position-handler="navEditMenu.updatePosition" />
</w-menu>
</w-btn>
<w-separator vertical />
</template>
<w-btn class="flex-1" icon="la:bookmark" label="Bookmarks" flat @click="notImplemented" />
<!-- -> Edit Nav is the whole bar now, so it is also what decides whether there is one -->
<w-bar v-if="canEditNav" class="sidebar-footerbtns text-white" dense>
<w-btn class="flex-1" icon="la:dharmachakra" label="Edit Nav" flat>
<w-menu ref="navEditMenu" anchor="top left" self="bottom left" :offset="[0, 10]">
<nav-edit-menu
:menu-hide-handler="navEditMenu.hide"
:update-position-handler="navEditMenu.updatePosition" />
</w-menu>
</w-btn>
</w-bar>
</template>
</w-drawer>
@ -131,7 +117,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { useMinWidth } from '@/composables/screen'
import { useI18n } from 'vue-i18n'
@ -236,15 +221,6 @@ const showSidebarActions = computed(() => siteStore.locales.showMenu || canBrows
const canEditNav = computed(() => {
return userStore.authenticated && userStore.can('manage:navigation')
})
// METHODS
function notImplemented() {
notify({
type: 'negative',
message: 'Not implemented'
})
}
</script>
<style lang="scss">

@ -186,6 +186,99 @@
</w-item>
</template>
</w-card>
<!-- ----------------------- -->
<!-- Rate Limiting -->
<!-- ----------------------- -->
<w-card class="pb-2 mt-4">
<w-card-header>{{ t('admin.security.rateLimit') }}</w-card-header>
<!--
First thing in the card, and in the same red the security warning above uses: both say
something that decides whether the settings under them do what they look like they do.
-->
<w-item class="pt-0">
<w-item-section>
<w-card class="bg-negative text-white rounded" flat>
<w-card-section class="items-center" horizontal>
<w-card-section class="flex-none pr-0">
<w-icon name="la:exclamation-triangle" size="lg" />
</w-card-section>
<w-card-section class="text-caption">
<!-- -> With `trustProxy` off behind a proxy every request carries the proxy's
address, so one visitor going over the limit takes everybody with them -->
<div v-if="!state.config.trustProxy">
{{ t('admin.security.rateLimitProxyWarn') }}
</div>
<div :class="{ 'mt-1': !state.config.trustProxy }">
{{ t('admin.security.rateLimitRecommended') }}
</div>
</w-card-section>
</w-card-section>
</w-card>
</w-item-section>
</w-item>
<w-item tag="label">
<blueprint-icon icon="filtration" />
<w-item-section>
<w-item-label>{{ t(`admin.security.rateLimitEnabled`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.security.rateLimitEnabledHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.authRateLimitEnabled"
:aria-label="t(`admin.security.rateLimitEnabled`)" />
</w-item-section>
</w-item>
<template v-if="state.config.authRateLimitEnabled">
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="pin-pad" />
<w-item-section>
<w-item-label>{{ t(`admin.security.rateLimitMax`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.security.rateLimitMaxHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 200px">
<w-input
outlined
v-model.number="state.config.authRateLimitMax"
dense
:suffix="t(`admin.security.rateLimitMaxSuffix`)"
:aria-label="t(`admin.security.rateLimitMax`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="timer" />
<w-item-section>
<w-item-label>{{ t(`admin.security.rateLimitWindow`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.security.rateLimitWindowHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 200px">
<w-input
outlined
v-model="state.config.authRateLimitWindow"
dense
:placeholder="t(`admin.security.durationPlaceholder`)"
:aria-label="t(`admin.security.rateLimitWindow`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="denied" />
<w-item-section>
<w-item-label>{{ t(`admin.security.rateLimitBan`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.security.rateLimitBanHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 200px">
<w-input
outlined
v-model="state.config.authRateLimitBan"
dense
:placeholder="t(`admin.security.durationPlaceholder`)"
:aria-label="t(`admin.security.rateLimitBan`)" />
</w-item-section>
</w-item>
</template>
</w-card>
</div>
<div class="col-span-12 lg:col-span-6">
<!-- ----------------------- -->
@ -412,6 +505,10 @@ const state = reactive({
forceAssetDownload: false,
hstsDuration: 0,
trustProxy: false,
authRateLimitEnabled: true,
authRateLimitMax: 10,
authRateLimitWindow: '5m',
authRateLimitBan: '15m',
authJwtAudience: 'urn:wiki.js',
authJwtExpiration: '30m',
authJwtRenewablePeriod: '14d',

@ -3,14 +3,88 @@
<div class="w-section-header">{{ t('inbox.watching') }}</div>
<div class="p-4">
<div class="text-body2">{{ t('inbox.watchingInfo') }}</div>
<!--
The empty state carries the instruction with it: this screen is reached from the sidebar, quite
possibly before the reader has ever noticed the bell it is telling them about.
-->
<w-banner
v-if="state.pages.length < 1 && state.loading < 1"
class="mt-6"
rounded
:class="dark.isActive ? `bg-dark-4 text-grey-4` : `bg-grey-2 text-grey-8`">
<div>{{ t('inbox.watchingNone') }}</div>
<div class="text-caption mt-1 opacity-70">{{ t('inbox.watchingHint') }}</div>
</w-banner>
<w-list v-else class="mt-6" bordered separator>
<w-item v-for="page of state.pages" :key="page.pageId" clickable @click="openPage(page)">
<w-item-section avatar>
<!--
The page's own icon, which is what it is recognised by everywhere else. It is a reference
a USER picked, so it resolves through `/_icons` rather than the bundled set see WIcon.
-->
<w-avatar color="secondary" text-color="white" rounded>
<w-icon :name="page.icon || DEFAULT_PAGE_ICON" />
</w-avatar>
</w-item-section>
<w-item-section>
<w-item-label>
<strong>{{ page.title }}</strong>
</w-item-label>
<w-item-label caption>/{{ page.path }}</w-item-label>
<w-item-label caption>
{{ t('inbox.watchingUpdated', { date: humanizeDate(page.updatedAt) }) }}
&middot;
{{ t('inbox.watchingSince', { date: humanizeDate(page.watchedAt) }) }}
</w-item-label>
</w-item-section>
<w-item-section side>
<!--
`@click.stop`, so pressing Stop Watching does not also follow the row to the page it is
about which would leave the reader on a page they just said they were done with.
-->
<!-- -> `mdi`, to match the bell this is the undoing of; see the page header -->
<w-btn
class="acrylic-btn"
flat
dense
icon="mdi:bell-off-outline"
color="grey"
:aria-label="t(`inbox.watchingUnwatch`)"
:disable="state.unwatching === page.pageId"
@click.stop="unwatch(page)">
<w-tooltip>{{ t('inbox.watchingUnwatch') }}</w-tooltip>
</w-btn>
</w-item-section>
</w-item>
</w-list>
</div>
</w-page>
</template>
<script setup>
import { onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { DEFAULT_PAGE_ICON, usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
// COMPOSABLES
const dark = useDark()
// ROUTER
const router = useRouter()
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
@ -21,4 +95,86 @@ const { t } = useI18n()
useMeta({
title: t('inbox.watching')
})
// DATA
const state = reactive({
loading: 0,
pages: [],
/** The page whose Stop Watching is in flight, so its button cannot be pressed twice. */
unwatching: null
})
// MOUNTED
onMounted(load)
// METHODS
/** The reason the API gave, out of a response ky threw on, or the error's own message. */
async function apiMessage(err) {
return (
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
)
}
function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})
}
async function load() {
state.loading++
try {
state.pages = (await API_CLIENT.get(`sites/${siteStore.id}/watching`).json()) ?? []
} catch (err) {
notify({
type: 'negative',
message: t('inbox.watchingLoadFailed'),
caption: await apiMessage(err)
})
}
state.loading--
}
function openPage(page) {
router.push(`/${page.path}`)
}
/**
* Stop watching a page from the list.
*
* The row goes as soon as the server confirms, rather than the whole list being fetched again: what
* changed is known exactly, and a reader unwatching three pages in a row should not watch the list
* rebuild three times.
*
* The page store is kept in step for the one case where it is about the same page the reader came
* here from it, and going back must not find a bell still saying it is watched.
*/
async function unwatch(page) {
state.unwatching = page.pageId
try {
await API_CLIENT.delete(`sites/${siteStore.id}/pages/${page.pageId}/watch`)
state.pages = state.pages.filter((p) => p.pageId !== page.pageId)
if (pageStore.id === page.pageId) {
pageStore.$patch({ isWatching: false })
}
notify({
type: 'positive',
message: t('inbox.watchingUnwatched', { title: page.title })
})
} catch (err) {
notify({
type: 'negative',
message: t('inbox.watchingUnwatchFailed'),
caption: await apiMessage(err)
})
}
state.unwatching = null
}
</script>

@ -88,7 +88,12 @@ export const usePageStore = defineStore('page', {
/** Whether this reader reviews this page, which is what shows the review button on it. */
canReview: false,
/** The suggestions waiting on this page, oldest first. Empty for everybody who is not its reviewer. */
pendingSubmissions: []
pendingSubmissions: [],
/**
* Whether this reader has asked to be told about changes to this page. Always false for a guest:
* a watch belongs to an account, which is what a notification would eventually be sent to.
*/
isWatching: false
}),
getters: {
breadcrumbs: (state) => {
@ -206,6 +211,30 @@ export const usePageStore = defineStore('page', {
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
},
/**
* PAGE - WATCH / UNWATCH
*
* Asks to be told about changes to this page, or stops asking.
*
* The store is moved first and put back if the server refuses. A bell that waits for a round trip
* before it rings is a bell that feels broken, and the request behind it either succeeds or is
* worth an error there is no third outcome to leave the button guessing at.
*
* @throws Whatever the request failed with, for the caller to report.
*/
async pageWatch(watching) {
const siteStore = useSiteStore()
const previous = this.isWatching
this.isWatching = watching
try {
const url = `sites/${siteStore.id}/pages/${this.id}/watch`
await (watching ? API_CLIENT.put(url) : API_CLIENT.delete(url))
} catch (err) {
this.isWatching = previous
console.warn(err)
throw err
}
},
/**
* PAGE - APPLY VIEWER STATE
*
@ -230,7 +259,8 @@ export const usePageStore = defineStore('page', {
canSuggestEdits: viewer.canSuggestEdits === true,
hasOpenSuggestion: viewer.hasOpenSuggestion === true,
canReview: viewer.canReview === true,
pendingSubmissions: viewer.pendingSubmissions ?? []
pendingSubmissions: viewer.pendingSubmissions ?? [],
isWatching: viewer.isWatching === true
})
},
/**
@ -270,6 +300,7 @@ export const usePageStore = defineStore('page', {
hasOpenSuggestion: false,
canReview: false,
pendingSubmissions: [],
isWatching: false,
notFound: true
})
},

Loading…
Cancel
Save