feat: Audit Log

scarlett
NGPixel 5 days ago
parent 7cf47610d1
commit d39f2eff0f
No known key found for this signature in database

@ -323,12 +323,18 @@ There are **two kinds of permission**, granted separately and checked in differe
kind a name belongs to decides how it may be enforced, so it is the first thing to establish about
any permission you touch.
**Global permissions** are held site-wide, bound to no path: `access:admin`, `manage:users`,
`manage:groups`, `manage:navigation`, `manage:theme`, `manage:sites`, `manage:system`. That list is
the whole of it — the one offered by the group editor (`GroupEditOverlay.vue`). They live on a
group's `permissions` column, are flattened onto `req.session.permissions` at login
(`models/users.ts` → `updateSession`), and are what the per-route `config.permissions` hook
checks. `manage:system` bypasses every check everywhere.
**Global permissions** are held site-wide, bound to no path: `access:admin`, `read:users`,
`manage:users`, `read:groups`, `manage:groups`, `read:audit`, `manage:navigation`, `manage:theme`,
`manage:sites`, `manage:system`. That is the list as it stands — the one offered by the group editor
(`GroupEditOverlay.vue`). They live on a group's `permissions` column, are flattened onto
`req.session.permissions` at login (`models/users.ts` → `updateSession`), and are what the per-route
`config.permissions` hook checks. `manage:system` bypasses every check everywhere.
**Adding a global permission is the maintainer's call, not yours.** The list is not frozen, but a new
name reshapes who can do what across the whole instance and every existing group silently lacks it —
so propose it and wait for a yes before writing any code that names it. Until then, express what a
route needs with the permissions that already exist. This is about *adding* to the list; using one
that is already on it needs no permission from anybody.
**Page rule permissions** are bound to paths, and to locales and sites: `read:pages`, `write:pages`,
`review:pages`, `manage:pages`, `delete:pages`, `write:styles`, `write:scripts`, `read:source`,
@ -357,8 +363,10 @@ Consequences worth knowing:
- **An anonymous request is the guests group**, not an absence of groups: that is how a wiki opens
reading, and suggesting edits, to the public. Deny guests explicitly where an account is genuinely
required (`reviewerFor` in `api/approvals.ts` is the worked example).
- **Never invent a permission name.** Both lists above are closed; `can('browse:fileman')` and
friends matched nothing and silently hid the controls they guarded.
- **Never invent a permission name.** Nothing validates one, so a name that is not on the lists above
simply never matches: `can('browse:fileman')` and friends silently hid the controls they guarded.
Adding a genuinely new global permission is allowed but is the maintainer's decision — ask first,
as above; never introduce one on your own.
### Backend patterns
@ -722,6 +730,55 @@ store; no SVG is ever written into content.
- Picking an icon calls `POST /_api/icons/materialize`, which is what guarantees the wiki can serve it
afterwards without the Iconify API.
### Audit log
Every action a **person** takes is one row in `auditLog``userId`, `clientIP`, `ts`, `kind`
(`page` / `asset` / `auth` / `profile` / `admin`), `action`, and a `meta` blob. Read at
`/_admin/audit` behind the `read:audit` permission; the retention setting behind `manage:system`,
because shortening it destroys evidence and that is not the same authority as looking.
- **Written from API route handlers**, through `audit(req, kind, action, meta)` in `helpers/audit.ts`
and only ever AFTER the work succeeded. That is what excludes the scheduler by construction: a page
the git sync imports reaches `pages.adoptStoredPage` by a path that never passes through a route,
so it leaves no row. Don't move an audit call into a model to save a few lines — the model is
reachable from a job, and the log would start claiming a person did it.
- **The exceptions are the auth events with no session yet**, which record themselves in
`models/users.ts`: a login, a registration, an email confirmation, a password reset from a link, a
forced password change. The account is only identified there, from a credential or a token, and the
login case additionally converges six routes (local, provider, passkey, and the 2FA and
password-change continuations) on one place. A logout is the mirror image and passes its own actor,
since the session is destroyed before the entry is written.
- **`AUDIT_ACTIONS` in `models/auditLog.ts` is the closed list**, grouped by kind, and `AuditAction`
is its union — so `npm run typecheck` refuses an action that is not in it. Each key is also its
translation key (`admin.audit.actions.<action>`) and what `GET /audit/actions` serves the filter
from, so adding an action means adding the string too. Keys are unique ACROSS kinds, which is why
`forcedPasswordChange` (demanded at sign-in) and `changePassword` (from one's own profile) are
named apart.
- **What is recorded and what is not.** Every mutating route, plus successful logins. Not reads —
page views would bury everything else. Not FAILED logins: that endpoint is open to whoever can
reach the wiki, so recording them would let anybody outside fill the table on demand
(`models/rateLimits.ts` is what answers that). `requestPasswordReset` is recorded only when a link
was actually sent, for the same reason.
- **`meta` carries identity, never payload and never content.** A page edit records the `pageHistory`
version its change produced — which is why `createPage` / `updatePage` / `movePage` / `deletePage`
return a `PageChange` rather than a page — instead of copying the before and after into a second
table. Configuration routes record which fields were touched, not what they were set to; `security`
is the one exception, recorded in full because none of it is a secret and its values are exactly
what gets asked about later. `sanitizeMeta` redacts secret-shaped keys as a backstop, not as the
rule.
- **`meta.actor` is a copy of the email, display name and IP as they stood.** `userId` is
`on delete: set null`, so an entry outlives the account that made it — the copy is the whole reason
the row is still readable, and the admin area reads the user column from it rather than from the
users table, so a rename does not rewrite history.
- **Rows are purged, not kept for ever.** `purgeAuditLog` runs daily off `SYSTEM_SCHEDULE`, against
the `audit.retentionDays` setting (`0` keeps everything). **The floor is `MIN_RETENTION_DAYS`, 30**,
and it is not a preference: retention is the one setting whose whole effect is to destroy this
table, and `manage:system` — the permission that changes it — belongs to exactly the person the
table exists to record. Left free, acting and then setting retention to a day would purge the
record before anybody had reason to look. So the API refuses anything between 1 and 30, and
`retentionDays()` reads such a value AS 30 rather than honouring it — the second check is what
closes the config-file and direct-database routes round the first.
### GraphQL is being removed
An earlier iteration of 3.x used GraphQL/Apollo. **All of it is deprecated** — there is no GraphQL

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import type { FastifyInstance } from 'fastify'
import type { KeyExpiration } from '../models/apiKeys.ts'
@ -121,6 +122,14 @@ async function routes(app: FastifyInstance) {
groups: req.body.groups
})
// -> Never the token: it exists once, in the response above, and this log is not a second copy
await audit(req, 'admin', 'createApiKey', {
apiKeyId: id,
name: req.body.name,
expiration: req.body.expiration,
groups: req.body.groups
})
return {
ok: true,
message: 'API key created successfully.',
@ -181,6 +190,8 @@ async function routes(app: FastifyInstance) {
await WIKI.models.apiKeys.revokeKey(key.id)
await audit(req, 'admin', 'revokeApiKey', { apiKeyId: key.id, name: key.name })
return {
ok: true,
message: 'API key revoked successfully.'

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import { CustomError } from '../helpers/common.ts'
import { actorFrom, mayBypassPassword, mayOnPage, unlockedFor } from './pages.ts'
import type { ApprovalPageRef, ApprovalRulePatch, ReviewerScope } from '../models/approvals.ts'
@ -264,6 +265,13 @@ async function routes(app: FastifyInstance) {
}
const rule = await WIKI.models.approvals.createRule(req.params.siteId, req.body)
await audit(req, 'admin', 'createApprovalRule', {
ruleId: rule.id,
siteId: req.params.siteId,
name: rule.name
})
return {
ok: true,
rule
@ -345,6 +353,14 @@ async function routes(app: FastifyInstance) {
if (!rule) {
return reply.notFound('Approval rule does not exist.')
}
await audit(req, 'admin', 'updateApprovalRule', {
ruleId: rule.id,
siteId: req.params.siteId,
name: rule.name,
changedFields: Object.keys(req.body)
})
return {
ok: true,
rule
@ -391,6 +407,12 @@ async function routes(app: FastifyInstance) {
if (!(await WIKI.models.approvals.deleteRule(req.params.siteId, req.params.ruleId))) {
return reply.notFound('Approval rule does not exist.')
}
await audit(req, 'admin', 'deleteApprovalRule', {
ruleId: req.params.ruleId,
siteId: req.params.siteId
})
return reply.code(204).send()
}
)
@ -538,6 +560,19 @@ async function routes(app: FastifyInstance) {
if (!applied) {
return reply.notFound('This edit suggestion does not exist.')
}
// -> Approving writes the suggestion onto the page through `updatePage`, so the edit itself is
// in that page's history under the reviewer's name — this entry records the decision
await audit(req, 'page', 'approvePageEdit', {
submissionId: req.params.submissionId,
pageId: submission.page.id,
siteId: req.params.siteId,
locale: submission.page.locale,
path: submission.page.path,
submittedBy: submission.author.name,
submittedByGuest: submission.author.isGuest
})
return {
ok: true,
message: 'Edit suggestion approved.'
@ -585,6 +620,17 @@ async function routes(app: FastifyInstance) {
return reply.notFound('This edit suggestion does not exist.')
}
await WIKI.models.approvals.rejectSubmission(req.params.siteId, req.params.submissionId)
await audit(req, 'page', 'rejectPageEdit', {
submissionId: req.params.submissionId,
pageId: submission.page.id,
siteId: req.params.siteId,
locale: submission.page.locale,
path: submission.page.path,
submittedBy: submission.author.name,
submittedByGuest: submission.author.isGuest
})
return {
ok: true,
message: 'Edit suggestion declined.'
@ -847,6 +893,20 @@ async function routes(app: FastifyInstance) {
guestEmail
})
/*
Recorded even for a guest, who has no account for `userId` to point at: the entry is the only
record that somebody outside the wiki sent this in, and the name and email they gave are on
`meta` rather than on `meta.actor` those two are the account's, and there is no account.
*/
await audit(req, 'page', 'submitPageEdit', {
submissionId: submission.id,
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path,
...(actor ? {} : { guestName, guestEmail })
})
return {
ok: true,
submission: { id: submission.id, updatedAt: submission.updatedAt }

@ -1,5 +1,6 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { audit } from '../helpers/audit.ts'
import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts'
import { INLINE_EXTS } from '../models/assets.ts'
@ -181,6 +182,17 @@ async function routes(app: FastifyInstance) {
authorId
})
// -> The stored name, not the one that was sent: an upload conflict may have renamed the file
// or replaced one that was already there, and the entry has to say what actually happened
await audit(req, 'asset', 'uploadAsset', {
assetId: asset.id,
siteId: req.params.siteId,
locale,
folderPath: destination,
fileName: asset.fileName,
fileSize: asset.fileSize
})
return {
ok: true,
message: 'Asset uploaded successfully.',
@ -386,6 +398,19 @@ async function routes(app: FastifyInstance) {
if (!asset) {
return reply.notFound('This asset does not exist.')
}
await audit(req, 'asset', 'updateAsset', {
assetId: asset.id,
siteId: req.params.siteId,
locale: destination.locale,
folderPath: destination.folderPath,
fileName: destination.fileName,
previousLocale: existing.locale,
previousFolderPath: existing.folderPath,
previousFileName: existing.fileName,
isRelocated
})
return {
ok: true,
message: isRelocated ? 'Asset moved successfully.' : 'Asset renamed successfully.',
@ -432,6 +457,15 @@ async function routes(app: FastifyInstance) {
) {
return reply.notFound('This asset does not exist.')
}
await audit(req, 'asset', 'deleteAsset', {
assetId: req.params.assetId,
siteId: req.params.siteId,
locale: doomed.locale,
folderPath: doomed.folderPath,
fileName: doomed.fileName
})
return reply.code(204).send()
}
)

@ -0,0 +1,350 @@
import { Readable } from 'node:stream'
import { validate as uuidValidate } from 'uuid'
import type { FastifyInstance, FastifyReply } from 'fastify'
import { audit } from '../helpers/audit.ts'
import { AUDIT_ACTIONS, AUDIT_KINDS, MIN_RETENTION_DAYS } from '../models/auditLog.ts'
/** Most rows one request may ask for, so that a filter matching everything cannot be a denial of service. */
const MAX_PAGE_SIZE = 100
/** Most accounts one request may name, so the `userId` list cannot be an unbounded `IN`. */
const MAX_USER_FILTER = 50
/** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */
function splitList(value?: string): string[] {
return (
value
?.split(',')
.map((v) => v.trim())
.filter(Boolean) ?? []
)
}
/** The filter half of the querystring, shared by the list and the export. */
interface AuditQuery {
userId?: string
kind?: string
action?: string
from?: string
to?: string
}
/**
* The filters as the model takes them, or the refusal to send back.
*
* The user IDs are validated rather than passed through: they go into an `IN` against a uuid column,
* so one malformed value makes postgres raise and the request fail as a 500 rather than as the bad
* request it is.
*/
function filtersFrom(
query: AuditQuery,
reply: FastifyReply
): { userIds: string[]; kind?: string; action?: string; from?: string; to?: string } | null {
const userIds = splitList(query.userId)
if (userIds.length > MAX_USER_FILTER) {
reply.badRequest(`At most ${MAX_USER_FILTER} users can be filtered for at once.`)
return null
}
if (userIds.some((id) => !uuidValidate(id))) {
reply.badRequest('The userId filter must be one user ID, or several separated by commas.')
return null
}
return { userIds, kind: query.kind, action: query.action, from: query.from, to: query.to }
}
/** The filter querystring both routes declare. */
const filterProperties = {
userId: {
type: 'string',
description:
'Only entries these accounts made — one ID, or several comma-separated, OR-ed against each other. At most 50.\n\nAn account that has since been deleted can no longer be filtered for: its entries have no `userId` left. The name and email it had are still on `meta.actor`.'
},
kind: { $ref: 'AuditKind#' },
action: { $ref: 'AuditAction#' },
from: {
type: 'string',
format: 'date-time',
description: 'Inclusive lower bound on the timestamp, RFC 3339.'
},
to: {
type: 'string',
format: 'date-time',
description: 'Inclusive upper bound on the timestamp, RFC 3339.'
}
} as const
/**
* Audit Log API Routes
*
* Read-only from the outside: nothing here writes an entry. Entries are written by `helpers/audit.ts`
* as a side effect of the routes that do the work, which is what keeps the log a record of what
* happened rather than a table anybody can post to.
*/
async function routes(app: FastifyInstance) {
/**
* LIST AUDIT ENTRIES
*/
app.get<{
Querystring: {
userId?: string
kind?: string
action?: string
from?: string
to?: string
page?: number
limit?: number
}
}>(
'/',
{
config: {
permissions: ['read:audit']
},
schema: {
summary: 'List audit log entries',
description:
'Newest first, one page at a time. Every filter is optional and they are AND-ed together.\n\nWhat is recorded: every action that CHANGES something, plus successful logins. Reads are not — page views would outnumber everything else and bury the log — and neither are failed login attempts, which would otherwise let anybody outside fill this table on demand. Actions taken by the scheduler are absent by construction: an entry is only ever written from a request.',
tags: ['Audit Log'],
querystring: {
type: 'object',
properties: {
...filterProperties,
page: { type: 'integer', minimum: 1, default: 1 },
limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 25 }
}
},
response: {
200: {
description: 'One page of audit entries',
type: 'object',
properties: {
total: {
type: 'integer',
description: 'How many entries match the filters, across every page.'
},
entries: {
type: 'array',
items: { $ref: 'AuditEntry#' }
}
}
}
}
}
},
async (req, reply) => {
reply.preventCache()
const filters = filtersFrom(req.query, reply)
if (!filters) {
return reply
}
return WIKI.models.auditLog.list(filters, req.query.page ?? 1, req.query.limit ?? 25)
}
)
/**
* EXPORT AUDIT ENTRIES
*/
app.get<{ Querystring: AuditQuery }>(
'/export',
{
config: {
permissions: ['read:audit']
},
schema: {
summary: 'Download the audit log as JSONL',
description:
'Every entry matching the filters, newest first — not just the page on screen — as newline-delimited JSON: one entry per line, the same shape the list returns. A format that streams, so a year of history is a download rather than a request that has to be held in memory at both ends, and that `jq` and every log pipeline read without unwrapping an envelope first.\n\nThe export itself is recorded in the log, which is the one read that is: taking a copy of who did what is worth knowing about.',
tags: ['Audit Log'],
querystring: {
type: 'object',
properties: filterProperties
},
response: {
200: {
description: 'The matching entries, one JSON object per line',
content: {
'application/x-ndjson': {
schema: { type: 'string' }
}
}
}
}
}
},
async (req, reply) => {
const filters = filtersFrom(req.query, reply)
if (!filters) {
return reply
}
/*
Recorded BEFORE the stream, deliberately. Everywhere else an entry is written once the work
succeeded, but a download has no moment of success the server sees the connection can drop
halfway and the rows already sent are still gone. What is worth recording is that the export
was asked for and authorized.
*/
await audit(req, 'admin', 'exportAuditLog', {
filters: {
userIds: filters.userIds,
kind: filters.kind ?? null,
action: filters.action ?? null,
from: filters.from ?? null,
to: filters.to ?? null
}
})
const stamp = Temporal.Now.instant().toString({ smallestUnit: 'second' }).replace(/[:]/g, '-')
reply.header('Content-Type', 'application/x-ndjson; charset=utf-8')
reply.header('Content-Disposition', `attachment; filename="audit-log-${stamp}.jsonl"`)
reply.preventCache()
// -> The entries this request just wrote are in the export too, which is correct: it is the log
// as it stands at the moment it was asked for
const entries = WIKI.models.auditLog.stream(filters)
return reply.send(
Readable.from(
(async function* () {
for await (const entry of entries) {
yield `${JSON.stringify(entry)}\n`
}
})()
)
)
}
)
/**
* LIST AUDIT ACTIONS
*/
app.get(
'/actions',
{
config: {
permissions: ['read:audit']
},
schema: {
summary: 'List every action the wiki records, by area',
description:
'What the filter offers, and the full set of translation keys: each action is shown as `admin.audit.actions.<action>`. Served from the code rather than from the table, so an action nothing has done yet is still offered.',
tags: ['Audit Log'],
response: {
200: {
description: 'Action keys grouped by area',
type: 'array',
items: {
type: 'object',
properties: {
kind: { $ref: 'AuditKind#' },
actions: {
type: 'array',
items: { type: 'string' }
}
}
}
}
}
}
},
async () => {
return AUDIT_KINDS.map((kind) => ({ kind, actions: [...AUDIT_ACTIONS[kind]] }))
}
)
/**
* GET AUDIT CONFIG
*/
app.get(
'/config',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the audit log retention settings',
description:
'Reading the log needs `read:audit`; changing how long it is kept is a system setting and needs `manage:system` — shortening the retention destroys evidence, which is not the same authority as looking at it.',
tags: ['Audit Log'],
response: {
200: { $ref: 'AuditConfig#' }
}
}
},
async (req, reply) => {
reply.preventCache()
const [total, oldestEntry] = await Promise.all([
WIKI.models.auditLog.total(),
WIKI.models.auditLog.oldestEntry()
])
return {
retentionDays: WIKI.models.auditLog.retentionDays(),
total,
oldestEntry
}
}
)
/**
* UPDATE AUDIT CONFIG
*/
app.put<{ Body: { retentionDays: number } }>(
'/config',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Set how long audit log entries are kept',
description: `Takes effect the next time the daily \`purgeAuditLog\` task runs; nothing is deleted by saving. The change is itself recorded in the log.
Either zero keep everything for ever or at least ${MIN_RETENTION_DAYS} days. Nothing between the two is accepted: retention is the one setting whose effect is to destroy this table, and the person who can change it is the person it exists to record, so a value short enough to outrun discovery is refused rather than offered.`,
tags: ['Audit Log'],
body: {
type: 'object',
properties: {
retentionDays: {
type: 'integer',
minimum: 0,
maximum: 36500,
description: `Days to keep. Zero keeps everything for ever; anything else is at least ${MIN_RETENTION_DAYS}.`
}
},
required: ['retentionDays']
},
response: {
200: {
description: 'Retention updated',
type: 'object',
properties: {
retentionDays: { type: 'integer' }
}
}
}
}
},
async (req, reply) => {
/*
Checked here rather than in the JSON Schema: "zero or at least thirty" is expressible as an
`anyOf`, but what comes back from one is a validation error naming neither branch, and this
is a refusal whose reason is the whole point of it.
*/
if (req.body.retentionDays > 0 && req.body.retentionDays < MIN_RETENTION_DAYS) {
return reply.badRequest(
`Audit log retention must be at least ${MIN_RETENTION_DAYS} days, or zero to keep entries for ever.`
)
}
const previous = WIKI.models.auditLog.retentionDays()
await WIKI.models.settings.updateConfig('audit', { retentionDays: req.body.retentionDays })
WIKI.config.audit = { ...WIKI.config.audit, retentionDays: req.body.retentionDays }
await audit(req, 'admin', 'updateAuditConfig', {
retentionDays: req.body.retentionDays,
previousRetentionDays: previous
})
return { retentionDays: req.body.retentionDays }
}
)
}
export default routes

@ -1,4 +1,5 @@
import { nanoid } from 'nanoid'
import { audit } from '../helpers/audit.ts'
import { maskSensitiveProps } from '../helpers/common.ts'
import { limitAuthAttempts } from '../helpers/rateLimit.ts'
import type { AuthStrategy } from '../models/authentication.ts'
@ -534,7 +535,7 @@ async function routes(app: FastifyInstance) {
},
async (req, reply) => {
try {
await WIKI.models.users.verifyUserEmail(req.body.token)
await WIKI.models.users.verifyUserEmail(req.body.token, req.ip)
return { ok: true }
} catch (err: any) {
WIKI.models.flags.authDebug(`Email confirmation refused: ${err.message}`)
@ -686,7 +687,8 @@ async function routes(app: FastifyInstance) {
try {
await WIKI.models.users.resetPassword({
token: req.body.token,
newPassword: req.body.newPassword
newPassword: req.body.newPassword,
ip: req.ip
})
return { ok: true }
} catch (err: any) {
@ -1182,6 +1184,14 @@ async function routes(app: FastifyInstance) {
email: user.email
}
})
// -> Not through `audit()`: the session was destroyed above, so the request no longer knows
// who made it and the actor has to come from the copy taken before that
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'logout',
actor: { id: user.id, name: user.name, email: user.email, ip: req.ip },
meta: { siteId: req.params.siteId }
})
}
return {
@ -1340,6 +1350,14 @@ async function routes(app: FastifyInstance) {
const id = await WIKI.models.authentication.createStrategy(req.body as any)
// -> The module and the display name, never the config: a strategy's config is where its client
// secret lives
await audit(req, 'admin', 'createAuthStrategy', {
strategyId: id,
module: req.body.module,
displayName: req.body.displayName
})
return {
ok: true,
message: 'Authentication strategy created successfully.',
@ -1429,6 +1447,14 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('Failed to update the authentication strategy.')
}
// -> Which fields were touched, not what they were set to, for the same reason as above
await audit(req, 'admin', 'updateAuthStrategy', {
strategyId: current.id,
module: current.module,
displayName: current.displayName,
changedFields: Object.keys(patch)
})
return {
ok: true,
message: 'Authentication strategy updated successfully.'
@ -1477,6 +1503,13 @@ async function routes(app: FastifyInstance) {
}
await WIKI.models.authentication.deleteStrategy(req.params.strategyId)
await audit(req, 'admin', 'deleteAuthStrategy', {
strategyId: req.params.strategyId,
module: strategy.module,
displayName: strategy.displayName
})
return reply.code(204).send()
}
)

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
/**
@ -181,6 +182,12 @@ async function routes(app: FastifyInstance) {
try {
const updated = await WIKI.models.blocks.setBlocksState(req.params.siteId, req.body.states)
await audit(req, 'admin', 'updateBlock', {
siteId: req.params.siteId,
states: req.body.states
})
return {
ok: true,
message: 'Blocks state updated successfully.',
@ -244,6 +251,13 @@ async function routes(app: FastifyInstance) {
}
await WIKI.models.blocks.deleteCustomBlock(req.params.siteId, req.params.blockId)
await audit(req, 'admin', 'deleteBlock', {
siteId: req.params.siteId,
blockId: req.params.blockId,
name: block.name
})
return reply.code(204).send()
}
)

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import { CustomError } from '../helpers/common.ts'
import { SYSTEM_PERMISSION } from '../models/groups.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
@ -129,6 +130,9 @@ async function routes(app: FastifyInstance) {
try {
const id = await WIKI.models.groups.createGroup(req.body.name)
await audit(req, 'admin', 'createGroup', { groupId: id, name: req.body.name })
return {
ok: true,
message: 'Group created successfully.',
@ -332,6 +336,19 @@ async function routes(app: FastifyInstance) {
try {
await WIKI.models.groups.updateGroup(group.id, patch)
/*
The permissions and the rules are recorded in full rather than as "changed", because they
ARE the answer to who may do what and reconstructing the set somebody was granted at a
point in time is the question an audit log gets asked about a group.
*/
await audit(req, 'admin', 'updateGroup', {
groupId: group.id,
name: patch.name ?? group.name,
changedFields: Object.keys(patch),
...(patch.permissions !== undefined ? { permissions: patch.permissions } : {}),
...(patch.rules !== undefined ? { rules: patch.rules } : {})
})
return {
ok: true,
message: 'Group updated successfully.'
@ -391,6 +408,9 @@ async function routes(app: FastifyInstance) {
try {
await WIKI.models.groups.deleteGroup(group.id)
await audit(req, 'admin', 'deleteGroup', { groupId: group.id, name: group.name })
return reply.code(204).send()
} catch (err: any) {
WIKI.logger.warn(err)
@ -547,6 +567,14 @@ async function routes(app: FastifyInstance) {
return reply.conflict('User is already assigned to this group.')
}
await audit(req, 'admin', 'assignUserToGroup', {
groupId: group.id,
groupName: group.name,
targetUserId: user.id,
targetName: user.name,
targetEmail: user.email
})
return {
ok: true,
message: 'User assigned to group successfully.'
@ -619,6 +647,15 @@ async function routes(app: FastifyInstance) {
}
await WIKI.models.groups.unassignUserFromGroup(group.id, req.params.userId)
await audit(req, 'admin', 'unassignUserFromGroup', {
groupId: group.id,
groupName: group.name,
targetUserId: req.params.userId,
targetName: user?.name ?? null,
targetEmail: user?.email ?? null
})
return reply.code(204).send()
}
)

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import type { FastifyInstance } from 'fastify'
import { EMITTED_EVENTS, HOOK_EVENTS } from '../models/hooks.ts'
@ -195,6 +196,14 @@ async function routes(app: FastifyInstance) {
authHeader: req.body.authHeader
})
// -> No `authHeader`: a webhook's auth header is a credential for the endpoint it calls
await audit(req, 'admin', 'createHook', {
hookId: id,
name: req.body.name,
url: req.body.url,
events: req.body.events
})
return {
ok: true,
message: 'Webhook created successfully.',
@ -272,6 +281,13 @@ async function routes(app: FastifyInstance) {
await WIKI.models.hooks.updateHook(req.params.hookId, patch)
await audit(req, 'admin', 'updateHook', {
hookId: req.params.hookId,
name: patch.name,
url: patch.url,
changedFields: Object.keys(patch)
})
return {
ok: true,
message: 'Webhook updated successfully.'
@ -312,6 +328,9 @@ async function routes(app: FastifyInstance) {
if (!(await WIKI.models.hooks.deleteHook(req.params.hookId))) {
return reply.notFound('Webhook does not exist.')
}
await audit(req, 'admin', 'deleteHook', { hookId: req.params.hookId })
return reply.code(204).send()
}
)

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import type { FastifyInstance } from 'fastify'
/**
@ -88,6 +89,9 @@ async function routes(app: FastifyInstance) {
async (req, reply) => {
try {
const set = await WIKI.models.icons.addSet(req.body.prefix.toLowerCase())
await audit(req, 'admin', 'addIconSet', { prefix: set.prefix, name: set.name })
return {
ok: true,
message: `The ${set.name} icon set has been added.`,
@ -155,6 +159,9 @@ async function routes(app: FastifyInstance) {
return reply.notFound('Icon set has not been added.')
}
await WIKI.models.icons.setSetState(prefix, req.body.isEnabled)
await audit(req, 'admin', 'updateIconSet', { prefix, isEnabled: req.body.isEnabled })
return {
ok: true,
message: `The ${prefix} icon set has been ${req.body.isEnabled ? 'enabled' : 'disabled'}.`
@ -211,6 +218,11 @@ async function routes(app: FastifyInstance) {
return reply.notFound('Icon set has not been added.')
}
const deletedIcons = await WIKI.models.icons.deleteSet(prefix)
// -> The icon count matters: deleting a set drops every icon stored for it, so anything a page
// still references from that prefix stops resolving
await audit(req, 'admin', 'deleteIconSet', { prefix, deletedIcons })
return {
ok: true,
message: `The ${prefix} icon set has been deleted.`,
@ -285,9 +297,12 @@ async function routes(app: FastifyInstance) {
}
}
},
async (_req, reply) => {
async (req, reply) => {
try {
const refreshed = await WIKI.models.icons.refreshSets()
await audit(req, 'admin', 'refreshIconSets', { refreshed })
return {
ok: true,
message: `Refreshed ${refreshed} icon sets.`,
@ -477,6 +492,14 @@ async function routes(app: FastifyInstance) {
},
async (req) => {
const failed = await WIKI.models.icons.materializeIcons(req.body.icons)
// -> Counts rather than the references themselves: picking an icon calls this, so a list of
// every name somebody browsed past would be noise rather than a record
await audit(req, 'admin', 'materializeIcons', {
requested: req.body.icons.length,
failed: failed.length
})
return {
ok: failed.length < 1,
message:
@ -566,8 +589,11 @@ async function routes(app: FastifyInstance) {
}
}
},
async () => {
async (req) => {
await WIKI.models.icons.purgeCache()
await audit(req, 'admin', 'flushIconCache', {})
return {
ok: true,
message: 'The icon cache has been purged.'

@ -8,6 +8,7 @@ async function routes(app: FastifyInstance) {
await import('./schemas/apiKey.ts').then((m) => m.registerSchemas(app))
await import('./schemas/approval.ts').then((m) => m.registerSchemas(app))
await import('./schemas/asset.ts').then((m) => m.registerSchemas(app))
await import('./schemas/audit.ts').then((m) => m.registerSchemas(app))
await import('./schemas/authentication.ts').then((m) => m.registerSchemas(app))
await import('./schemas/block.ts').then((m) => m.registerSchemas(app))
await import('./schemas/extension.ts').then((m) => m.registerSchemas(app))
@ -29,6 +30,7 @@ async function routes(app: FastifyInstance) {
app.register(import('./apiKeys.ts'), { prefix: '/api-keys' })
app.register(import('./approvals.ts'))
app.register(import('./assets.ts'))
app.register(import('./auditLog.ts'), { prefix: '/audit' })
app.register(import('./authentication.ts'))
app.register(import('./blocks.ts'))
app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' })

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import type { FastifyInstance } from 'fastify'
/**
@ -46,7 +47,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Fetch the latest locales from the Wiki.js repository',
description:
"Reads the published locale metadata and records any locale not seen before as available. An installed locale is re-downloaded only when its published hash differs from the one stored, so a run that finds nothing new costs a single request.\n\n`en` is never fetched: it is the locale the interface is written in and ships with the wiki, loaded from `locales/en.json` on every boot. It counts as unchanged.",
'Reads the published locale metadata and records any locale not seen before as available. An installed locale is re-downloaded only when its published hash differs from the one stored, so a run that finds nothing new costs a single request.\n\n`en` is never fetched: it is the locale the interface is written in and ships with the wiki, loaded from `locales/en.json` on every boot. It counts as unchanged.',
tags: ['Locales'],
response: {
200: {
@ -69,8 +70,12 @@ async function routes(app: FastifyInstance) {
}
}
},
async () => {
return { ok: true, ...(await WIKI.models.locales.updateFromRemote()) }
async (req) => {
const result = await WIKI.models.locales.updateFromRemote()
await audit(req, 'admin', 'fetchLocales', result)
return { ok: true, ...result }
}
)
@ -86,7 +91,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Download the strings of an available locale',
description:
"Downloads the published strings file for a locale that has a row but no strings, making it installable on a site. Fetch the locale list first: a locale nobody has heard of yet has no row to install.\n\nRefused for `en`, which ships with the wiki and is always installed.",
'Downloads the published strings file for a locale that has a row but no strings, making it installable on a site. Fetch the locale list first: a locale nobody has heard of yet has no row to install.\n\nRefused for `en`, which ships with the wiki and is always installed.',
tags: ['Locales'],
params: {
type: 'object',
@ -112,6 +117,8 @@ async function routes(app: FastifyInstance) {
} catch (err: any) {
return reply.badRequest(err.message)
}
await audit(req, 'admin', 'installLocale', { code: req.params.code })
return { ok: true, message: 'Locale installed successfully.' }
}
)
@ -175,6 +182,12 @@ async function routes(app: FastifyInstance) {
} catch (err: any) {
return reply.badRequest(err.message)
}
await audit(req, 'admin', 'updateLocale', {
code: req.params.code,
customName: req.body?.customName ?? null,
customCode: req.body?.customCode ?? null
})
return { ok: true, message: 'Aliases updated successfully.' }
}
)

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import type { FastifyInstance } from 'fastify'
/**
@ -133,6 +134,10 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('Failed to save mail configuration.')
}
// -> Which settings were touched, never their values: `patch` carries the SMTP password and
// the DKIM private key
await audit(req, 'admin', 'updateMailConfig', { changedFields: Object.keys(patch) })
return {
ok: true,
message: 'Mail configuration updated successfully.'
@ -204,6 +209,8 @@ async function routes(app: FastifyInstance) {
baseUrl: WIKI.models.mail.baseUrl({ req, siteId })
}
})
await audit(req, 'admin', 'sendTestEmail', { recipient: req.body.recipient, siteId })
return {
ok: true,
message: 'Test email sent successfully.'

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { NAVIGATION_MODES, type NavigationItem, type NavigationMode } from '../models/navigation.ts'
@ -214,6 +215,16 @@ async function routes(app: FastifyInstance) {
mode: req.body.mode,
items: req.body.items
})
// -> The mode and how many items, not the tree itself: a sidebar is hundreds of entries and
// the point of the record is that somebody changed the navigation of this page
await audit(req, 'admin', 'updatePageNavigation', {
siteId: req.params.siteId,
pageId: req.params.pageId,
mode: result.navigationMode,
navigationId: result.navigationId,
itemCount: req.body.items?.length ?? 0
})
return {
ok: true,
message: 'Navigation updated successfully.',

@ -2,6 +2,7 @@ import { validate as uuidValidate } from 'uuid'
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 { audit } from '../helpers/audit.ts'
import { generatePathHash, normalizePagePath } from '../helpers/common.ts'
import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts'
@ -176,6 +177,81 @@ async function loadReadablePage(req: FastifyRequest, siteId: string, pageId: str
* Pages API Routes
*/
async function routes(app: FastifyInstance) {
/**
* LIST RECENTLY EDITED PAGES
*/
app.get<{ Querystring: { limit?: number } }>(
'/pages/recent',
{
config: {
// -> `access:admin`, not a page permission: this fills a panel on the admin dashboard, which
// everyone who can open the admin area sees, and it is the same permission
// `users/recent-logins` fills the panel beside it with. Page rules are not consulted, so
// the answer is deliberately thin -- where a page is and when it was last written, and
// nothing of what it says.
permissions: ['access:admin']
},
schema: {
summary: 'List the most recently edited pages',
description:
'What has been written lately, newest first, across every site. Ordered by the last write, which covers a creation as well as an edit — `isNew` says which of the two this row is.',
tags: ['Pages'],
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 }
}
},
response: {
200: {
description: 'The most recently edited pages, newest first',
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
siteId: { type: 'string', format: 'uuid' },
locale: { type: 'string' },
path: { type: 'string' },
title: { type: 'string' },
updatedAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
},
isNew: {
type: 'boolean',
description:
'Whether this is the page first appearing rather than a later edit of it.'
},
url: {
type: 'string',
description:
"Where the page is, as a path on its own site — carrying a locale prefix only where that site's settings put one there."
},
hostname: {
type: 'string',
nullable: true,
description:
'The host that site answers on, for linking to a page on a site other than the one being browsed. Null for the catch-all site.'
},
authorName: {
type: 'string',
nullable: true,
description: 'Who wrote the version that stands. Null once that account is gone.'
}
}
}
}
}
}
},
async (req, reply) => {
reply.preventCache()
return WIKI.models.pages.getRecentlyEdited({ limit: req.query.limit ?? 10 })
}
)
/**
* LIST PAGES
*/
@ -676,6 +752,17 @@ async function routes(app: FastifyInstance) {
request, and it is the reader's own deliberate action that starts it.
*/
req.session.unlockedPages = [...new Set([...(req.session.unlockedPages ?? []), page.id])]
// -> The one read this log records, because it is a password being accepted rather than a page
// being looked at. A wrong password is not recorded, for the same reason a failed login is
// not: this endpoint is open to anybody who can reach the page.
await audit(req, 'page', 'unlockPage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path
})
return page
}
)
@ -725,7 +812,21 @@ async function routes(app: FastifyInstance) {
if (!mayOnPage(req, 'write:pages', { path: req.body.path, locale: req.body.locale })) {
return reply.forbidden('You are not allowed to create a page here.')
}
const page = await WIKI.models.pages.createPage(req.params.siteId, req.body, actor)
const { page, versionId } = await WIKI.models.pages.createPage(
req.params.siteId,
req.body,
actor
)
await audit(req, 'page', 'createPage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path,
title: page.title,
versionId
})
return {
ok: true,
message: 'Page created successfully.',
@ -780,15 +881,27 @@ async function routes(app: FastifyInstance) {
if (!mayOnPage(req, 'write:pages', target)) {
return reply.forbidden('You are not allowed to edit this page.')
}
const page = await WIKI.models.pages.updatePage(
const change = await WIKI.models.pages.updatePage(
req.params.siteId,
req.params.pageId,
req.body,
actor
)
if (!page) {
if (!change) {
return reply.notFound('This page does not exist.')
}
const { page, versionId } = change
// -> What changed is not repeated here: `versionId` points at the `pageHistory` row that holds
// the page as it now stands, which is the record of the edit itself
await audit(req, 'page', 'updatePage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path,
title: page.title,
versionId
})
/*
Anyone else editing this page right now is looking at the text that was just stored, so their
editor should stop calling it unsaved. Told through the collaboration room rather than answered
@ -892,15 +1005,27 @@ async function routes(app: FastifyInstance) {
) {
return reply.forbidden('You are not allowed to move this page there.')
}
const page = await WIKI.models.pages.movePage(
const change = await WIKI.models.pages.movePage(
req.params.siteId,
req.params.pageId,
req.body,
actor
)
if (!page) {
if (!change) {
return reply.notFound('This page does not exist.')
}
const { page, versionId } = change
await audit(req, 'page', 'movePage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path,
previousLocale: target.locale,
previousPath: target.path,
versionId
})
return {
ok: true,
message: 'Page moved successfully.',
@ -964,6 +1089,14 @@ async function routes(app: FastifyInstance) {
if (!queued) {
return reply.notFound('This page does not exist.')
}
await audit(req, 'page', 'renderPage', {
pageId: req.params.pageId,
siteId: req.params.siteId,
locale: target.locale,
path: target.path
})
return reply.code(202).send({
ok: true,
message: 'Page queued for rendering.'
@ -1008,9 +1141,26 @@ async function routes(app: FastifyInstance) {
if (!mayOnPage(req, 'delete:pages', target)) {
return reply.forbidden('You are not allowed to delete this page.')
}
if (!(await WIKI.models.pages.deletePage(req.params.siteId, req.params.pageId, actor))) {
const deleted = await WIKI.models.pages.deletePage(
req.params.siteId,
req.params.pageId,
actor
)
if (!deleted) {
return reply.notFound('This page does not exist.')
}
// -> The version recorded here is the deletion itself, which is what recovering the page would
// be built from — so the entry says where to find the page that is no longer there
await audit(req, 'page', 'deletePage', {
pageId: req.params.pageId,
siteId: req.params.siteId,
locale: target.locale,
path: target.path,
title: target.title,
versionId: deleted.versionId
})
return reply.code(204).send()
}
)

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import type { FastifyInstance } from 'fastify'
import { JOB_STATES, type JobState } from '../models/jobs.ts'
@ -89,6 +90,17 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('The scheduler could not queue the job.')
}
/*
A scheduled task run by hand IS a user action, and is recorded as one what the task itself
then does is not, since by the time it runs there is no request and no requester. This entry
is the link between the two: the job id is what `jobHistory` records the outcome against.
*/
await audit(req, 'admin', 'runScheduledTask', {
scheduleId: req.params.scheduleId,
task: entry.task,
jobId: id
})
return {
ok: true,
message: 'Task queued successfully.',
@ -161,6 +173,9 @@ async function routes(app: FastifyInstance) {
if (!cancelled) {
return reply.notFound('No pending job with this ID.')
}
await audit(req, 'admin', 'cancelJob', { jobId: req.params.jobId })
return reply.code(204).send()
}
)
@ -284,6 +299,12 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('The scheduler could not queue the job.')
}
await audit(req, 'admin', 'retryJob', {
task: entry.task,
retriedJobId: req.params.jobId,
jobId: id
})
return {
ok: true,
message: 'Job queued successfully.',

@ -0,0 +1,91 @@
import type { FastifyInstance } from 'fastify'
import { AUDIT_ACTION_KEYS, AUDIT_KINDS, MIN_RETENTION_DAYS } from '../../models/auditLog.ts'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* AUDIT ENTRY - One recorded action
*/
app.addSchema({
$id: 'AuditEntry',
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
ts: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
},
kind: {
$ref: 'AuditKind#'
},
action: {
type: 'string',
description:
'What was done, camelCase. A translation key rather than a sentence — the interface looks it up as `admin.audit.actions.<action>`.'
},
clientIP: {
type: 'string',
description: 'Address the request came from.'
},
userId: {
type: 'string',
format: 'uuid',
nullable: true,
description:
'The account that acted, while it exists. Null once it is deleted, and null for an action nobody was signed in for — the name and email on `meta.actor` are what the entry is read by then.'
},
meta: {
type: 'object',
additionalProperties: true,
description:
'What the action touched, plus an `actor` block holding the email, display name and address the requester had at the time. Never carries a secret, and never the content of a change — a page edit records the `pageHistory` version its change produced instead.'
}
}
})
/**
* AUDIT KIND - The areas an action can belong to
*/
app.addSchema({
$id: 'AuditKind',
type: 'string',
enum: [...AUDIT_KINDS]
})
/**
* AUDIT ACTION - Every action key the wiki records
*/
app.addSchema({
$id: 'AuditAction',
type: 'string',
enum: AUDIT_ACTION_KEYS
})
/**
* AUDIT CONFIG - How long entries are kept
*/
app.addSchema({
$id: 'AuditConfig',
type: 'object',
properties: {
retentionDays: {
type: 'integer',
minimum: 0,
description: `How many days of log to keep. Entries older than this are deleted daily by the \`purgeAuditLog\` task. Zero keeps everything for ever, and anything else is at least ${MIN_RETENTION_DAYS} — see the PUT for why there is a floor.`
},
total: {
type: 'integer',
description: 'How many entries the log currently holds.'
},
oldestEntry: {
type: 'string',
format: 'date-time',
nullable: true,
description: 'When the oldest entry was recorded, or null when the log is empty.'
}
}
})
}

@ -1,4 +1,5 @@
import { validate as uuidValidate } from 'uuid'
import { audit } from '../helpers/audit.ts'
import { CustomError, normalizePastedDestination } from '../helpers/common.ts'
import { detectImageMime, detectSvg, imageMimeTypes, svgMimeType } from '../helpers/images.ts'
import { siteAssetKinds } from '../models/sites.ts'
@ -238,6 +239,13 @@ async function routes(app: FastifyInstance) {
const result = await WIKI.models.sites.createSite(req.body.hostname, {
title: req.body.title
})
await audit(req, 'admin', 'createSite', {
siteId: result.id,
hostname: req.body.hostname,
title: req.body.title
})
return {
ok: true,
message: 'Site created successfully.',
@ -494,6 +502,20 @@ async function routes(app: FastifyInstance) {
isEnabled: req.body.isEnabled,
...(Object.keys(config).length < 1 ? {} : { config })
})
// -> The config is a large nested blob covering everything from the theme to the storage
// layout, so its top-level sections are what is recorded rather than the whole of it
await audit(req, 'admin', 'updateSite', {
siteId: req.params.siteId,
...(req.body.hostname !== undefined ? { hostname: req.body.hostname } : {}),
...(req.body.isEnabled !== undefined ? { isEnabled: req.body.isEnabled } : {}),
changedFields: [
...(req.body.hostname !== undefined ? ['hostname'] : []),
...(req.body.isEnabled !== undefined ? ['isEnabled'] : []),
...Object.keys(config).map((section) => `config.${section}`)
]
})
return {
ok: true,
message: 'Site updated successfully.'
@ -571,6 +593,11 @@ async function routes(app: FastifyInstance) {
await WIKI.models.sites.setAsset(req.params.siteId, req.params.kind, data)
await audit(req, 'admin', 'updateSiteImage', {
siteId: req.params.siteId,
kind: req.params.kind
})
return {
ok: true,
message: 'Image uploaded successfully.'
@ -631,6 +658,11 @@ async function routes(app: FastifyInstance) {
await WIKI.models.sites.clearAsset(req.params.siteId, req.params.kind)
await audit(req, 'admin', 'deleteSiteImage', {
siteId: req.params.siteId,
kind: req.params.kind
})
return {
ok: true,
message: 'Image cleared successfully.'
@ -668,10 +700,18 @@ async function routes(app: FastifyInstance) {
}
},
async (req, reply) => {
// -> Read before it goes, so that the entry can say which site this was rather than only which
// id it had
const doomed = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
try {
if ((await WIKI.models.sites.countSites()) <= 1) {
reply.conflict('Cannot delete the last site. At least 1 site must exist at all times.')
} else if (await WIKI.models.sites.deleteSite(req.params.siteId)) {
await audit(req, 'admin', 'deleteSite', {
siteId: req.params.siteId,
hostname: doomed?.hostname ?? null,
title: doomed?.config?.title ?? null
})
reply.code(204)
} else {
reply.badRequest('Site does not exist.')

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import { maskSensitiveProps } from '../helpers/common.ts'
import { STORAGE_DIRECT_ACCESS_FALLBACKS, STORAGE_TARGET_STATUSES } from '../models/storage.ts'
import type { FastifyInstance } from 'fastify'
@ -282,6 +283,22 @@ async function routes(app: FastifyInstance) {
}
}
/*
Which targets were written and which site-wide settings were touched never the target
configs themselves, which are where the S3 secret keys and the SSH passwords live. A target's
module and title are enough to say what was changed.
*/
await audit(req, 'admin', 'updateStorage', {
siteId: req.params.siteId,
targets: patches.map(({ target, patch }) => ({
targetId: target.id,
module: target.module,
title: target.title,
changedFields: Object.keys(patch).filter((key) => key !== 'id')
})),
changedFields: Object.keys(req.body).filter((key) => key !== 'targets')
})
return {
ok: true,
message: 'Storage configuration updated successfully.',
@ -361,6 +378,21 @@ async function routes(app: FastifyInstance) {
try {
const message = await WIKI.models.storage.executeAction(target, req.params.action, actorId)
/*
The action is recorded, not what it did. An import can create hundreds of pages and each of
those goes through `adoptStoredPage`, which is reached from a scheduled sync as well as from
here so those pages have their own history versions but no audit entries of their own.
This is the entry that says a person asked for it.
*/
await audit(req, 'admin', 'runStorageAction', {
siteId: req.params.siteId,
targetId: target.id,
module: target.module,
title: target.title,
action: req.params.action
})
return {
ok: true,
message: message ?? 'Action completed successfully.'

@ -11,6 +11,7 @@ import {
users as usersTable
} from '../db/schema.ts'
import maintenance from '../core/maintenance.ts'
import { audit } from '../helpers/audit.ts'
import { purgeTimeframes } from '../models/pageHistory.ts'
import type { PurgeTimeframe } from '../models/pageHistory.ts'
import type { FastifyInstance } from 'fastify'
@ -292,6 +293,8 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('Failed to save the system flags.')
}
await audit(req, 'admin', 'updateFlags', patch)
return {
ok: true,
message: 'System flags updated successfully.'
@ -369,6 +372,10 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('Failed to save the security configuration.')
}
// -> The security settings are the one configuration blob recorded in full: none of them is a
// secret, and what a CSP or a rate limit was set to is precisely what gets asked about later
await audit(req, 'admin', 'updateSecurity', patch)
return {
ok: true,
message: 'Security configuration updated successfully.'
@ -498,6 +505,13 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('Failed to save the search configuration.')
}
await audit(req, 'admin', 'updateSearchConfig', {
...(req.body.termHighlighting !== undefined
? { termHighlighting: req.body.termHighlighting }
: {}),
...(req.body.dictOverrides !== undefined ? { dictOverrides: req.body.dictOverrides } : {})
})
return {
ok: true,
message: 'Search configuration updated successfully.'
@ -545,6 +559,9 @@ async function routes(app: FastifyInstance) {
if (!added?.id) {
return reply.internalServerError('The scheduler could not queue the rebuild.')
}
await audit(req, 'admin', 'rebuildSearchIndex', { jobId: added.id })
return {
ok: true,
message: 'Search index rebuild queued successfully.',
@ -653,6 +670,12 @@ async function routes(app: FastifyInstance) {
// to wonder why nothing changed.
const restartRequired = WIKI.models.extensions.hasLoadFailed(definition)
await audit(req, 'admin', 'installExtension', {
extensionKey: req.params.extensionKey,
title: definition.title,
restartRequired
})
return {
ok: true,
message: restartRequired
@ -746,6 +769,8 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('Failed to save the API state.')
}
await audit(req, 'admin', 'updateApiState', { isEnabled: req.body.isEnabled })
return {
ok: true,
message: req.body.isEnabled ? 'API enabled successfully.' : 'API disabled successfully.',
@ -837,6 +862,8 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('Failed to save the metrics endpoint state.')
}
await audit(req, 'admin', 'updateMetricsState', { isEnabled: req.body.isEnabled })
return {
ok: true,
message: req.body.isEnabled
@ -934,9 +961,12 @@ async function routes(app: FastifyInstance) {
}
}
},
async () => {
async (req) => {
const count = maintenance.disconnectWebsockets()
WIKI.events.outbound.emit('disconnectWebsockets')
await audit(req, 'admin', 'disconnectWebsockets', { count })
return {
ok: true,
message: `Closed ${count} websocket connection(s) on this instance.`,
@ -975,9 +1005,12 @@ async function routes(app: FastifyInstance) {
}
}
},
async () => {
async (req) => {
await maintenance.flushCaches()
WIKI.events.outbound.emit('flushCaches')
await audit(req, 'admin', 'flushCache', {})
return {
ok: true,
message: 'The cache has been flushed.'
@ -1059,6 +1092,9 @@ async function routes(app: FastifyInstance) {
if (invalidatedKeys === null) {
return reply.internalServerError('Failed to save the new certificates.')
}
await audit(req, 'admin', 'regenerateCertificates', { invalidatedKeys })
return {
ok: true,
message: `Certificates regenerated successfully. ${invalidatedKeys} API key(s) will have to be reissued.`,
@ -1101,8 +1137,11 @@ async function routes(app: FastifyInstance) {
}
}
},
async () => {
async (req) => {
const count = await WIKI.models.apiKeys.purgeRevoked()
await audit(req, 'admin', 'purgeApiKeys', { count })
return {
ok: true,
message: `Purged ${count} revoked API key(s).`,
@ -1151,6 +1190,9 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('Failed to save the new session secret.')
}
// -> Before the session goes, since that is where the entry's actor comes from
await audit(req, 'admin', 'invalidateSessions', { count })
/*
This request's own session, which the rows above no longer include but which would come
straight back without this: @fastify/session writes the session it is holding as the response
@ -1215,6 +1257,11 @@ async function routes(app: FastifyInstance) {
},
async (req) => {
const count = await WIKI.models.pageHistory.purge(req.body.olderThan)
// -> Worth recording precisely because of what it destroys: the versions this took away are
// what page audit entries older than it point at
await audit(req, 'admin', 'purgePageHistory', { olderThan: req.body.olderThan, count })
return {
ok: true,
message: `Purged ${count} page version(s).`,
@ -1283,6 +1330,8 @@ async function routes(app: FastifyInstance) {
id: req.session.user!.id,
permissions: req.session.permissions ?? []
})
await audit(req, 'admin', 'purgeSampleContent', { siteId: req.body.siteId, count })
return {
ok: true,
message: `Purged ${count} page(s) tagged ${SAMPLE_CONTENT_TAG}.`,
@ -1323,7 +1372,7 @@ async function routes(app: FastifyInstance) {
}
}
},
async () => {
async (req) => {
const renderJob = await WIKI.scheduler.addJob({
task: 'checkVersion',
maxRetries: 0,
@ -1332,6 +1381,9 @@ async function routes(app: FastifyInstance) {
// NOTE: `addJob` resolves to undefined if enqueueing failed, in which case this throws —
// preserving the existing behavior.
await renderJob!.promise
await audit(req, 'admin', 'checkForUpdate', { latest: WIKI.config.update.version })
return {
current: WIKI.version,
latest: WIKI.config.update.version,

@ -1,5 +1,6 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy, type TreeRow } from '../models/tree.ts'
import { audit } from '../helpers/audit.ts'
import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts'
import { actorFrom } from './pages.ts'
@ -560,6 +561,14 @@ async function routes(app: FastifyInstance) {
pathName: req.body.pathName,
title: req.body.title
})
await audit(req, 'page', 'createFolder', {
folderId: folder.id,
siteId: req.params.siteId,
locale,
path: target,
title: req.body.title
})
return {
ok: true,
message: 'Folder created successfully.',
@ -618,6 +627,15 @@ async function routes(app: FastifyInstance) {
title: req.body.title,
actorId: req.session.user?.id
})
await audit(req, 'page', 'updateFolder', {
folderId: req.params.folderId,
siteId: req.params.siteId,
locale: existing.locale,
path: folderPathOf(folder),
previousPath: folderPathOf(existing),
title: folder.title
})
return {
ok: true,
message: 'Folder renamed successfully.',
@ -710,6 +728,17 @@ async function routes(app: FastifyInstance) {
locale: destinationLocale,
actorId: req.session.user?.id
})
// -> One entry for a move that took a whole branch with it. The pages underneath moved rather
// than changed, so they have no history versions of their own to point at.
await audit(req, 'page', 'moveFolder', {
folderId: req.params.folderId,
siteId: req.params.siteId,
locale: destinationLocale,
path: folderPathOf(folder),
previousLocale: existing.locale,
previousPath: folderPathOf(existing)
})
return {
ok: true,
message: 'Folder moved successfully.',
@ -810,6 +839,16 @@ async function routes(app: FastifyInstance) {
locale: destinationLocale,
actor
})
await audit(req, 'page', 'duplicateFolder', {
folderId: folder.id,
siteId: req.params.siteId,
locale: destinationLocale,
path: folderPathOf(folder),
sourceFolderId: req.params.folderId,
sourceLocale: existing.locale,
sourcePath: folderPathOf(existing)
})
return {
ok: true,
message: 'Folder duplicated successfully.',
@ -878,6 +917,14 @@ async function routes(app: FastifyInstance) {
folderId: req.params.folderId,
hue: req.body.hue
})
await audit(req, 'page', 'setFolderColor', {
folderId: req.params.folderId,
siteId: req.params.siteId,
locale: existing.locale,
path: folderPathOf(existing),
hue: req.body.hue
})
return {
ok: true,
message: 'Folder colour set successfully.',
@ -928,6 +975,18 @@ async function routes(app: FastifyInstance) {
// asset actually live
await WIKI.models.pages.deleteOrphaned(req.params.siteId, removed.pages, actor)
await WIKI.models.assets.deleteOrphaned(req.params.siteId, removed.assets, actor.id)
// -> Counts rather than ids: a folder deletion can take hundreds of pages with it, and each of
// those already has its own history version recording the deletion
await audit(req, 'page', 'deleteFolder', {
folderId: req.params.folderId,
siteId: req.params.siteId,
locale: existing.locale,
path: folderPathOf(existing),
deletedPages: removed.pages.length,
deletedAssets: removed.assets.length
})
return reply.code(204).send()
}
)

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import { CustomError, rethrowAsBadRequest } from '../helpers/common.ts'
import { detectImageMime, imageMimeTypes } from '../helpers/images.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
@ -356,6 +357,12 @@ async function routes(app: FastifyInstance) {
cvd: profile.cvd
}
// -> The fields that were touched, not the values: `patch` carries whatever the profile form
// sends, and a field added to it later should not start appearing in the log by itself
await audit(req, 'profile', 'updateProfile', {
changedFields: Object.keys(patch)
})
return {
ok: true,
message: 'Profile updated successfully.',
@ -417,6 +424,8 @@ async function routes(app: FastifyInstance) {
// -> The account menu reads `hasAvatar` off the session on every page load
req.session.user = { ...req.session.user!, hasAvatar: true }
await audit(req, 'profile', 'updateAvatar', {})
return {
ok: true,
message: 'Avatar uploaded successfully.'
@ -463,6 +472,8 @@ async function routes(app: FastifyInstance) {
await WIKI.models.users.clearAvatar(userId)
req.session.user = { ...req.session.user!, hasAvatar: false }
await audit(req, 'profile', 'deleteAvatar', {})
return {
ok: true,
message: 'Avatar cleared successfully.'
@ -597,6 +608,9 @@ async function routes(app: FastifyInstance) {
// -> The session outlived the user it points at
return reply.unauthorized()
}
await audit(req, 'profile', 'updateEditorSettings', { editor: req.params.editor })
return { ok: true, config }
}
)
@ -730,6 +744,8 @@ async function routes(app: FastifyInstance) {
rethrowAsBadRequest(err)
}
await audit(req, 'profile', 'changePassword', { strategyId: req.body.strategyId })
return {
ok: true,
message: 'Password changed successfully.'
@ -789,6 +805,11 @@ async function routes(app: FastifyInstance) {
rethrowAsBadRequest(err)
}
await audit(req, 'profile', 'togglePasswordLogin', {
strategyId: req.body.strategyId,
isEnabled: req.body.isEnabled
})
return {
ok: true,
message: req.body.isEnabled ? 'Password login enabled.' : 'Password login disabled.'
@ -923,6 +944,8 @@ async function routes(app: FastifyInstance) {
rethrowAsBadRequest(err)
}
await audit(req, 'profile', 'enableTfa', { strategyId: req.body.strategyId })
return {
ok: true,
message: '2FA enabled successfully.'
@ -967,6 +990,8 @@ async function routes(app: FastifyInstance) {
rethrowAsBadRequest(err)
}
await audit(req, 'profile', 'disableTfa', { strategyId: req.params.strategyId })
return reply.code(204).send()
}
)
@ -1074,6 +1099,11 @@ async function routes(app: FastifyInstance) {
registrationResponse: req.body.registrationResponse as any,
pending: req.session.passkeyRegistration
})
await audit(req, 'profile', 'registerPasskey', {
passkeyId: passkey.id,
name: passkey.name
})
return {
ok: true,
passkey
@ -1123,6 +1153,9 @@ async function routes(app: FastifyInstance) {
if (!(await WIKI.models.passkeys.remove(userId, req.params.passkeyId))) {
return reply.notFound('You have no passkey with this ID.')
}
await audit(req, 'profile', 'deletePasskey', { passkeyId: req.params.passkeyId })
return reply.code(204).send()
}
)
@ -1218,6 +1251,8 @@ async function routes(app: FastifyInstance) {
return reply.internalServerError('Failed to save user defaults.')
}
await audit(req, 'admin', 'updateUserDefaults', { changedFields: Object.keys(patch) })
return {
ok: true,
message: 'User defaults updated successfully.'
@ -1447,6 +1482,13 @@ async function routes(app: FastifyInstance) {
})
} catch (err: any) {
WIKI.logger.warn(`Welcome email for new user ${id} failed: ${err.message}`)
await audit(req, 'admin', 'createUser', {
targetUserId: id,
name: req.body.name,
email: req.body.email,
groups: req.body.groups ?? [],
welcomeEmailSent: false
})
return {
ok: true,
message: 'User created successfully.',
@ -1455,6 +1497,17 @@ async function routes(app: FastifyInstance) {
}
}
}
// -> Who the account is for, and which groups it was put in — never the password it was
// given, which is the one thing about a new account that must not be recoverable from here
await audit(req, 'admin', 'createUser', {
targetUserId: id,
name: req.body.name,
email: req.body.email,
groups: req.body.groups ?? [],
welcomeEmailSent: Boolean(req.body.sendWelcomeEmail)
})
return {
ok: true,
message: 'User created successfully.',
@ -1651,6 +1704,21 @@ async function routes(app: FastifyInstance) {
if (req.body.auth !== undefined) {
await WIKI.models.users.setUserAuthFlags(req.params.userId, req.body.auth)
}
// -> Group membership is listed because who is in which group IS the permission model, so a
// change to it is the one thing here worth being able to read back without a diff
await audit(req, 'admin', 'updateUser', {
targetUserId: user.id,
targetName: user.name,
targetEmail: user.email,
changedFields: [
...Object.keys(patch),
...(req.body.groups !== undefined ? ['groups'] : []),
...(req.body.auth !== undefined ? ['auth'] : [])
],
...(req.body.groups !== undefined ? { groups: req.body.groups } : {})
})
return {
ok: true,
message: 'User updated successfully.'
@ -1733,6 +1801,12 @@ async function routes(app: FastifyInstance) {
if (!updated) {
return reply.notFound('User does not exist.')
}
await audit(req, 'admin', 'resetUserPassword', {
targetUserId: req.params.userId,
mustChangePassword: req.body.mustChangePassword ?? false
})
return {
ok: true,
message: 'User password updated successfully.'
@ -1804,6 +1878,11 @@ async function routes(app: FastifyInstance) {
siteId: req.body?.siteId,
req
})
await audit(req, 'admin', 'sendWelcomeEmail', {
targetUserId: req.params.userId,
siteId: req.body?.siteId ?? null
})
return {
ok: true,
message: 'Welcome email sent successfully.'
@ -1882,6 +1961,18 @@ async function routes(app: FastifyInstance) {
try {
await WIKI.models.users.deleteUser(user.id)
/*
The deleted account's own name and email are on `meta` rather than on `meta.actor`, which
belongs to whoever pressed the button. This is also the entry that explains why every OTHER
entry that user left behind now has a null `userId`.
*/
await audit(req, 'admin', 'deleteUser', {
targetUserId: user.id,
targetName: user.name,
targetEmail: user.email
})
return reply.code(204).send()
} catch (err: any) {
// -> Pages and assets reference users without a cascade, so a user who authored content

@ -1,3 +1,4 @@
import { audit } from '../helpers/audit.ts'
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
@ -95,6 +96,14 @@ async function routes(app: FastifyInstance) {
pageId: page.id,
userId
})
await audit(req, 'page', 'watchPage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path
})
return { ok: true, isWatching: true }
}
)
@ -135,6 +144,14 @@ async function routes(app: FastifyInstance) {
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 })
// -> No path or locale: the page was deliberately not loaded above, so the id is all this
// request ever knew about it
await audit(req, 'page', 'unwatchPage', {
pageId: req.params.pageId,
siteId: req.params.siteId
})
return { ok: true, isWatching: false }
}
)

@ -56,6 +56,14 @@ defaults:
# DB defaults
api:
isEnabled: false
audit:
# How many days of audit log to keep. Rows older than this are deleted by the `purgeAuditLog`
# task, which runs daily. Set to 0 to keep everything for ever.
#
# Either 0 or at least 30: a shorter window would let somebody act and have the record of it
# purged before anybody had reason to look. A value between the two is read as 30 rather than
# honoured, wherever it came from.
retentionDays: 90
mail:
senderName: ''
senderEmail: ''

@ -0,0 +1,15 @@
CREATE TABLE "auditLog" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"ts" timestamp DEFAULT now() NOT NULL,
"kind" varchar(16) NOT NULL,
"action" varchar(64) NOT NULL,
"clientIP" varchar(45) DEFAULT '' NOT NULL,
"meta" jsonb DEFAULT '{}' NOT NULL,
"userId" uuid
);
--> statement-breakpoint
CREATE INDEX "auditLog_ts_idx" ON "auditLog" ("ts" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "auditLog_userId_idx" ON "auditLog" ("userId","ts" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "auditLog_kind_idx" ON "auditLog" ("kind","ts" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "auditLog_action_idx" ON "auditLog" ("action","ts" DESC NULLS LAST);--> statement-breakpoint
ALTER TABLE "auditLog" ADD CONSTRAINT "auditLog_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL;

File diff suppressed because it is too large Load Diff

@ -16,5 +16,14 @@ export const relations = defineRelations(schema, (r) => ({
from: r.userKeys.userId,
to: r.users.id
})
},
auditLog: {
// -> Optional, and stays that way: the row outlives the account it points at, and reading the
// log after a deletion falls back to the name and email kept on `meta.actor`
user: r.one.users({
from: r.auditLog.userId,
to: r.users.id,
optional: true
})
}
}))

@ -114,6 +114,70 @@ export const assets = pgTable(
(table) => [index('assets_siteId_idx').on(table.siteId)]
)
// AUDIT LOG ---------------------------
/**
* What somebody did, one row per action.
*
* Only actions a *person* took: every row is written from an API route handler, which is the one
* place the requester's identity and address are both in hand, and is by construction never reached
* by the scheduler or a storage sync. A job that creates a page therefore leaves no row here, which
* is the point an audit log is a record of who did something, and "the wiki did it" is not an
* answer anybody audits.
*
* Reads are not recorded. Page views would outnumber everything else by orders of magnitude and
* bury the log, and failed logins are left out on purpose: a credential-stuffing run would otherwise
* fill the table on demand from the outside. A login that *succeeded* is recorded, since that is the
* event with consequences.
*
* Nothing here duplicates what another table already keeps. A page edit records the `pageHistory`
* version its change produced and nothing about the change itself the before and after live there,
* and copying them would make this table enormous as well as wrong the moment the two disagreed.
*/
export const auditLog = pgTable(
'auditLog',
{
id: uuid().primaryKey().defaultRandom(),
ts: timestamp().notNull().defaultNow(),
/**
* Which part of the wiki the action belongs to: `page`, `asset`, `auth`, `profile` or `admin`.
* A varchar rather than an enum, for the same reason `pageHistory.action` is one naming
* another area later should not need a migration. `AUDIT_KINDS` in `models/auditLog.ts` is the
* list that decides.
*/
kind: varchar({ length: 16 }).notNull(),
/**
* What was done, camelCase `createPage`, `editSite`, `login`. Deliberately a key rather than a
* sentence: it is what the interface looks a translation up by, and what a filter matches on.
* `AUDIT_ACTIONS` in `models/auditLog.ts` is the full list.
*/
action: varchar({ length: 64 }).notNull(),
/** Where the request came from. 45 characters is the longest an IPv6 address can be written. */
clientIP: varchar({ length: 45 }).notNull().default(''),
/**
* The context of the action: which page, site or account it touched, and always an `actor` block
* carrying the email, display name and address the requester had AT THE TIME. That copy is the
* point of it `userId` goes null when the account is deleted, and a log that then said only
* "somebody" would have lost exactly what it existed to record.
*
* Never anything secret. `sanitizeMeta` in `helpers/audit.ts` is the backstop, but the rule is
* that a route does not put a password, a token or a module's sensitive prop in here to begin
* with.
*/
meta: jsonb().notNull().default({}),
// -> Set null rather than cascade: deleting an account must not delete the record of what it
// did. The name and email on `meta.actor` are what the row is read by afterwards.
userId: uuid().references(() => users.id, { onDelete: 'set null' })
},
(table) => [
// -> The unfiltered view: newest first, which is the only order this table is ever read in
index('auditLog_ts_idx').on(table.ts.desc()),
// -> One index per filter, each carrying `ts` so that narrowing by it still comes back ordered
index('auditLog_userId_idx').on(table.userId, table.ts.desc()),
index('auditLog_kind_idx').on(table.kind, table.ts.desc()),
index('auditLog_action_idx').on(table.action, table.ts.desc())
]
)
// AUTHENTICATION ----------------------
export const authentication = pgTable('authentication', {
id: uuid().primaryKey().defaultRandom(),

@ -0,0 +1,135 @@
import type { FastifyRequest } from 'fastify'
import type { AuditAction, AuditActor, AuditKind } from '../models/auditLog.ts'
/**
* Key names whose value never belongs in the audit log.
*
* This is a backstop, not the rule. The rule is that a route hands `audit()` the identity of what it
* touched an id, a path, a name and not the payload it touched it with, which is why nearly every
* call site passes a handful of scalars. What this catches is the case where somebody later widens
* one of those calls to `req.body` and does not notice that the body carries a module's sensitive
* prop.
*
* Two lists rather than one, because the useful patterns come in two lengths. A long name can be
* matched anywhere in the key `token` has to catch `continuationToken` while a short one has to
* match the whole key or it starts eating innocent fields: `pass` as a substring redacts `passkeyId`,
* and `key` as a substring redacts every foreign key in here.
*/
const SENSITIVE_SUBSTRINGS = [
'password',
'passwd',
'secret',
'token',
'credential',
'privatekey',
'apikey',
'accesskey',
'authorization',
'authheader',
'securitycode'
]
/** Short, generic names that are only sensitive when they are the whole key. */
const SENSITIVE_EXACT = new Set([
'pass',
'key',
'salt',
'hash',
'otp',
'session',
'certs',
'cookie'
])
/** What a redacted value is replaced with, so that the shape of the meta is still readable. */
const REDACTED = '[redacted]'
/** How deep `sanitizeMeta` walks before it stops descending. */
const MAX_DEPTH = 6
function isSensitiveKey(key: string): boolean {
const lowered = key.toLowerCase()
return (
SENSITIVE_EXACT.has(lowered) ||
SENSITIVE_SUBSTRINGS.some((pattern) => lowered.includes(pattern))
)
}
/**
* Replace the value of any sensitive-looking key, however deeply nested.
*
* Returns a new structure; the caller's object is never modified. Anything that is not a plain object
* or array is passed through as-is, so a `Date` or a `Buffer` reaching here stays what it was and is
* left for `JSON.stringify` to deal with on its way into the jsonb column.
*/
export function sanitizeMeta(value: unknown, depth = 0): any {
if (depth > MAX_DEPTH) {
return REDACTED
}
if (Array.isArray(value)) {
return value.map((entry) => sanitizeMeta(entry, depth + 1))
}
if (value === null || typeof value !== 'object' || !isPlainObject(value)) {
return value
}
const cleaned: Record<string, any> = {}
for (const [key, entry] of Object.entries(value)) {
cleaned[key] = isSensitiveKey(key) ? REDACTED : sanitizeMeta(entry, depth + 1)
}
return cleaned
}
function isPlainObject(value: object): boolean {
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
/** Who is making this request, as the audit log records them. */
function actorFromRequest(req: FastifyRequest): AuditActor {
const user = req.session?.authenticated ? req.session.user : null
return {
id: user?.id ?? null,
name: user?.name ?? null,
email: user?.email ?? null,
ip: req.ip
}
}
/**
* Record one action in the audit log, as the request that made it.
*
* Called from API route handlers, AFTER the action has succeeded an audit log says what happened,
* and a row written before the work would claim something that a later `throw` never did. Being
* called from a route is also what keeps the scheduler out of the log: a page the git sync imports
* reaches the same model method by a path that never comes through here.
*
* This is how nearly everything is recorded. The exceptions are the auth events whose actor is not
* on the session yet a login, a registration, a password reset from an emailed link where the
* account is only identified deep inside `models/users.ts` by a credential or a token; those call
* `auditLog.record` directly, with the account they have just resolved. A logout is the mirror image:
* the session is destroyed before the entry is written, so it too passes its own actor.
*
* Awaiting it is optional and mostly pointless `auditLog.record` swallows its own failures but
* every call site does, so that a route's last statement is not a floating promise the response can
* outrun.
*
* A request authenticated by an API key rather than a session is recorded with no user: a key acts
* with the permissions of its groups, not as a person, and `meta.apiKeyId` is what identifies it.
*
* @param kind Which area of the wiki the action belongs to
* @param action What was done, as a key from `AUDIT_ACTIONS`
* @param meta The identity of what was touched. Never a payload, and never a secret.
*/
export async function audit(
req: FastifyRequest,
kind: AuditKind,
action: AuditAction,
meta: Record<string, any> = {}
): Promise<void> {
await WIKI.models.auditLog.record({
kind,
action,
actor: actorFromRequest(req),
meta: req.apiKey ? { ...meta, apiKeyId: req.apiKey.id } : meta
})
}

@ -117,7 +117,151 @@
"admin.approval.tagsRequired": "At least one tag is required.",
"admin.approval.title": "Approvals",
"admin.approval.updateSuccess": "Rule updated successfully.",
"admin.audit.accountGone": "This account has since been deleted",
"admin.audit.actions.addIconSet": "Added an icon set",
"admin.audit.actions.approvePageEdit": "Approved an edit suggestion",
"admin.audit.actions.assignUserToGroup": "Added a user to a group",
"admin.audit.actions.cancelJob": "Cancelled a pending job",
"admin.audit.actions.changePassword": "Changed their own password",
"admin.audit.actions.checkForUpdate": "Checked for an update",
"admin.audit.actions.createApiKey": "Created an API key",
"admin.audit.actions.createApprovalRule": "Created an approval rule",
"admin.audit.actions.createAuthStrategy": "Added an authentication strategy",
"admin.audit.actions.createFolder": "Created a folder",
"admin.audit.actions.createGroup": "Created a group",
"admin.audit.actions.createHook": "Created a webhook",
"admin.audit.actions.createPage": "Created a page",
"admin.audit.actions.createSite": "Created a site",
"admin.audit.actions.createUser": "Created a user",
"admin.audit.actions.deleteApprovalRule": "Deleted an approval rule",
"admin.audit.actions.deleteAsset": "Deleted a file",
"admin.audit.actions.deleteAuthStrategy": "Deleted an authentication strategy",
"admin.audit.actions.deleteAvatar": "Removed their avatar",
"admin.audit.actions.deleteBlock": "Deleted a custom block",
"admin.audit.actions.deleteFolder": "Deleted a folder",
"admin.audit.actions.deleteGroup": "Deleted a group",
"admin.audit.actions.deleteHook": "Deleted a webhook",
"admin.audit.actions.deleteIconSet": "Deleted an icon set",
"admin.audit.actions.deletePage": "Deleted a page",
"admin.audit.actions.deletePasskey": "Removed a passkey",
"admin.audit.actions.deleteSite": "Deleted a site",
"admin.audit.actions.deleteSiteImage": "Removed a site image",
"admin.audit.actions.deleteUser": "Deleted a user",
"admin.audit.actions.disableTfa": "Turned 2FA off",
"admin.audit.actions.disconnectWebsockets": "Closed the websocket connections",
"admin.audit.actions.duplicateFolder": "Duplicated a folder",
"admin.audit.actions.enableTfa": "Turned 2FA on",
"admin.audit.actions.exportAuditLog": "Exported the audit log",
"admin.audit.actions.fetchLocales": "Fetched the locale list",
"admin.audit.actions.flushCache": "Flushed the caches",
"admin.audit.actions.flushIconCache": "Purged the icon cache",
"admin.audit.actions.forcedPasswordChange": "Changed a password when required to at sign-in",
"admin.audit.actions.installExtension": "Installed an extension",
"admin.audit.actions.installLocale": "Installed a locale",
"admin.audit.actions.invalidateSessions": "Ended every session",
"admin.audit.actions.login": "Signed in",
"admin.audit.actions.logout": "Signed out",
"admin.audit.actions.materializeIcons": "Stored icons for offline use",
"admin.audit.actions.moveFolder": "Moved a folder",
"admin.audit.actions.movePage": "Moved or renamed a page",
"admin.audit.actions.purgeApiKeys": "Purged the revoked API keys",
"admin.audit.actions.purgePageHistory": "Purged page history",
"admin.audit.actions.purgeSampleContent": "Purged the sample content",
"admin.audit.actions.rebuildSearchIndex": "Rebuilt the search index",
"admin.audit.actions.refreshIconSets": "Refreshed the icon sets",
"admin.audit.actions.regenerateCertificates": "Regenerated the API key certificates",
"admin.audit.actions.register": "Registered an account",
"admin.audit.actions.registerPasskey": "Registered a passkey",
"admin.audit.actions.rejectPageEdit": "Declined an edit suggestion",
"admin.audit.actions.renderPage": "Queued a page for rendering",
"admin.audit.actions.requestPasswordReset": "Requested a password reset",
"admin.audit.actions.resetPassword": "Reset a password from an emailed link",
"admin.audit.actions.resetUserPassword": "Set a user's password",
"admin.audit.actions.retryJob": "Retried a job",
"admin.audit.actions.revokeApiKey": "Revoked an API key",
"admin.audit.actions.runScheduledTask": "Ran a scheduled task",
"admin.audit.actions.runStorageAction": "Ran a storage action",
"admin.audit.actions.sendTestEmail": "Sent a test email",
"admin.audit.actions.sendWelcomeEmail": "Sent a welcome email",
"admin.audit.actions.setFolderColor": "Changed a folder colour",
"admin.audit.actions.submitPageEdit": "Suggested an edit",
"admin.audit.actions.togglePasswordLogin": "Turned password sign-in on or off",
"admin.audit.actions.unassignUserFromGroup": "Removed a user from a group",
"admin.audit.actions.unlockPage": "Unlocked a password-protected page",
"admin.audit.actions.unwatchPage": "Stopped watching a page",
"admin.audit.actions.updateApiState": "Turned the API on or off",
"admin.audit.actions.updateApprovalRule": "Updated an approval rule",
"admin.audit.actions.updateAsset": "Renamed or moved a file",
"admin.audit.actions.updateAuditConfig": "Changed the audit log retention",
"admin.audit.actions.updateAuthStrategy": "Updated an authentication strategy",
"admin.audit.actions.updateAvatar": "Changed their avatar",
"admin.audit.actions.updateBlock": "Changed the blocks of a site",
"admin.audit.actions.updateEditorSettings": "Changed their editor settings",
"admin.audit.actions.updateFlags": "Changed the system flags",
"admin.audit.actions.updateFolder": "Renamed a folder",
"admin.audit.actions.updateGroup": "Updated a group",
"admin.audit.actions.updateHook": "Updated a webhook",
"admin.audit.actions.updateIconSet": "Enabled or disabled an icon set",
"admin.audit.actions.updateLocale": "Changed a locale alias",
"admin.audit.actions.updateMailConfig": "Updated the mail configuration",
"admin.audit.actions.updateMetricsState": "Turned the metrics endpoint on or off",
"admin.audit.actions.updatePage": "Edited a page",
"admin.audit.actions.updatePageNavigation": "Changed the navigation of a page",
"admin.audit.actions.updateProfile": "Updated their profile",
"admin.audit.actions.updateSearchConfig": "Changed the search configuration",
"admin.audit.actions.updateSecurity": "Changed the security configuration",
"admin.audit.actions.updateSite": "Updated a site",
"admin.audit.actions.updateSiteImage": "Uploaded a site image",
"admin.audit.actions.updateStorage": "Updated the storage configuration",
"admin.audit.actions.updateUser": "Updated a user",
"admin.audit.actions.updateUserDefaults": "Changed the user defaults",
"admin.audit.actions.uploadAsset": "Uploaded a file",
"admin.audit.actions.verifyEmail": "Confirmed an email address",
"admin.audit.actions.watchPage": "Started watching a page",
"admin.audit.allActions": "Any action",
"admin.audit.allKinds": "Any area",
"admin.audit.anonymous": "Not signed in",
"admin.audit.anyUser": "Anyone",
"admin.audit.clearFilters": "Clear filters",
"admin.audit.export": "Export",
"admin.audit.exportFailed": "Could not export the audit log.",
"admin.audit.field.action": "Action",
"admin.audit.field.clientIP": "IP address",
"admin.audit.field.from": "From",
"admin.audit.field.kind": "Area",
"admin.audit.field.meta": "Context",
"admin.audit.field.timestamp": "Timestamp",
"admin.audit.field.to": "To",
"admin.audit.field.user": "User",
"admin.audit.kinds.admin": "Admin",
"admin.audit.kinds.asset": "File",
"admin.audit.kinds.auth": "Sign-in",
"admin.audit.kinds.page": "Page",
"admin.audit.kinds.profile": "Profile",
"admin.audit.loadActionsFailed": "Could not load the list of actions.",
"admin.audit.loadFailed": "Could not load the audit log.",
"admin.audit.none": "Nothing has been recorded yet.",
"admin.audit.noneMatching": "No events match these filters.",
"admin.audit.pickUserTitle": "Filter by user",
"admin.audit.retention": "Retention",
"admin.audit.retention180": "6 months",
"admin.audit.retention30": "30 days",
"admin.audit.retention365": "1 year",
"admin.audit.retention730": "2 years",
"admin.audit.retention90": "90 days",
"admin.audit.retentionFloorHint": "There is no shorter option than 30 days: a window short enough to outrun discovery would let somebody purge the record of what they had just done.",
"admin.audit.retentionForever": "Forever",
"admin.audit.retentionHint": "How long entries are kept. Anything older is deleted once a day. Saving changes nothing on its own — the next daily run is what applies it.",
"admin.audit.retentionLoadFailed": "Could not load the retention settings.",
"admin.audit.retentionPeriod": "Keep entries for",
"admin.audit.retentionSaveFailed": "Could not save the retention settings.",
"admin.audit.retentionSaveSuccess": "Retention updated successfully.",
"admin.audit.retentionStats": "The log currently holds {count} entries, the oldest from {oldest}.",
"admin.audit.subtitle": "A log of all events by users across the wiki for auditing purposes",
"admin.audit.title": "Audit Log",
"admin.audit.userFilter": "User: {name}",
"admin.audit.userFilterMany": "User: {count} selected",
"admin.audit.viewDetails": "Details",
"admin.auth.activeStrategies": "Active Strategies",
"admin.auth.addPending": "{strategy} added. It is created when you press Apply.",
"admin.auth.addStrategy": "Add Strategy",
@ -204,7 +348,9 @@
"admin.dashboard.lastLoginsNone": "No logins recorded yet.",
"admin.dashboard.mostPopularPages": "Most Popular Pages",
"admin.dashboard.pages": "Pages",
"admin.dashboard.recentPages": "Recent Pages",
"admin.dashboard.recentPages": "Recently Edited",
"admin.dashboard.recentPagesAuthorGone": "Deleted user",
"admin.dashboard.recentPagesNone": "No pages have been written yet.",
"admin.dashboard.subtitle": "Wiki.js",
"admin.dashboard.title": "Dashboard",
"admin.dashboard.users": "Users",
@ -1050,6 +1196,7 @@
"admin.users.dateFormat": "Date Format",
"admin.users.dateFormatHint": "How dates should be formatted when displayed to the user.",
"admin.users.defaults": "Manage User Defaults",
"admin.users.defaultsButton": "User Defaults",
"admin.users.defaultsSaveSuccess": "User defaults saved successfully.",
"admin.users.delete": "Delete User",
"admin.users.deleteConfirmForeignNotice": "Note that you cannot delete a user that already created content. You must instead either deactivate the user or delete all content that was created by that user.",

@ -0,0 +1,415 @@
import { and, count, desc, eq, gte, inArray, lte, lt, or, sql } from 'drizzle-orm'
import { auditLog as auditLogTable } from '../db/schema.ts'
import { sanitizeMeta } from '../helpers/audit.ts'
/**
* The areas of the wiki an action can belong to.
*
* Closed in practice but stored as a varchar, so naming another one is a code change rather than a
* migration. The admin area's filter is built from this list, and `admin.audit.kinds.<kind>` is the
* translation of each.
*/
export const AUDIT_KINDS = ['page', 'asset', 'auth', 'profile', 'admin'] as const
export type AuditKind = (typeof AUDIT_KINDS)[number]
/**
* Every action the wiki records, by area.
*
* This is the authority: the admin area's action filter is built from it, `admin.audit.actions.<key>`
* is how each one is translated, and the documentation site lists it. A route recording an action
* that is not here is a bug nothing enforces it at runtime (an audit write must never fail a
* request), but `npm run typecheck` does, since `AuditAction` is the union of these.
*
* Keys are camelCase and unique ACROSS areas, so that one translation key means one thing. Where two
* areas would otherwise collide the more specific one is qualified `forcedPasswordChange` is the
* one a login demands, `changePassword` the one a user makes from their own profile.
*/
export const AUDIT_ACTIONS = {
page: [
'createPage',
'updatePage',
'movePage',
'deletePage',
'renderPage',
'unlockPage',
'watchPage',
'unwatchPage',
'submitPageEdit',
'approvePageEdit',
'rejectPageEdit',
'createFolder',
'updateFolder',
'moveFolder',
'duplicateFolder',
'setFolderColor',
'deleteFolder'
],
asset: ['uploadAsset', 'updateAsset', 'deleteAsset'],
auth: [
'login',
'logout',
'register',
'verifyEmail',
'requestPasswordReset',
'resetPassword',
'forcedPasswordChange'
],
profile: [
'updateProfile',
'updateAvatar',
'deleteAvatar',
'updateEditorSettings',
'changePassword',
'togglePasswordLogin',
'enableTfa',
'disableTfa',
'registerPasskey',
'deletePasskey'
],
admin: [
'createApiKey',
'revokeApiKey',
'createApprovalRule',
'updateApprovalRule',
'deleteApprovalRule',
'createAuthStrategy',
'updateAuthStrategy',
'deleteAuthStrategy',
'updateBlock',
'deleteBlock',
'createGroup',
'updateGroup',
'deleteGroup',
'assignUserToGroup',
'unassignUserFromGroup',
'createHook',
'updateHook',
'deleteHook',
'addIconSet',
'updateIconSet',
'deleteIconSet',
'refreshIconSets',
'materializeIcons',
'flushIconCache',
'fetchLocales',
'installLocale',
'updateLocale',
'updateMailConfig',
'sendTestEmail',
'updatePageNavigation',
'runScheduledTask',
'cancelJob',
'retryJob',
'createSite',
'updateSite',
'deleteSite',
'updateSiteImage',
'deleteSiteImage',
'updateStorage',
'runStorageAction',
'updateFlags',
'updateSecurity',
'updateSearchConfig',
'rebuildSearchIndex',
'installExtension',
'updateApiState',
'updateMetricsState',
'disconnectWebsockets',
'flushCache',
'regenerateCertificates',
'purgeApiKeys',
'invalidateSessions',
'purgePageHistory',
'purgeSampleContent',
'checkForUpdate',
'createUser',
'updateUser',
'resetUserPassword',
'sendWelcomeEmail',
'deleteUser',
'updateUserDefaults',
'updateAuditConfig',
'exportAuditLog'
]
} as const satisfies Record<AuditKind, readonly string[]>
export type AuditAction = (typeof AUDIT_ACTIONS)[AuditKind][number]
/** Every action key, flat and sorted — what the admin area's filter offers. */
export const AUDIT_ACTION_KEYS: string[] = Object.values(AUDIT_ACTIONS).flat().toSorted()
/**
* Who did it, as the row keeps it.
*
* The name and email are a copy taken at the time rather than a reference, because `userId` is set to
* null when the account is deleted and the row has to stay readable afterwards. Null for all three on
* an action nobody was signed in for a registration, a password reset from an emailed link.
*/
export interface AuditActor {
id: string | null
name: string | null
email: string | null
ip: string
}
/** One row as the API answers with it. */
export interface AuditEntry {
id: string
ts: Date
kind: string
action: string
clientIP: string
meta: Record<string, any>
userId: string | null
}
/** One page of the log, with the total matching the filters. */
export interface AuditLogPage {
total: number
entries: AuditEntry[]
}
/** What `list()` narrows by. Every field is optional; together they are AND-ed. */
export interface AuditLogFilters {
/**
* Accounts to narrow to, OR-ed against each other "what did any of these people do".
*
* A list rather than one id because the question an audit log gets asked is usually about a group
* of people, and because the picker behind it selects a set. Empty means everybody.
*/
userIds?: string[]
kind?: string
action?: string
/** Inclusive lower bound on `ts`, as an ISO instant. */
from?: string
/** Inclusive upper bound on `ts`, as an ISO instant. */
to?: string
}
/** How long rows are kept, in days. Zero means forever. */
export const DEFAULT_RETENTION_DAYS = 90
/**
* The shortest retention that may be configured, in days. Zero keep for ever is the only value
* below it.
*
* A floor rather than a preference, because retention is the one setting whose whole effect is to
* destroy this table, and the person who can change it is the person the table exists to record. A
* `manage:system` holder who could set it to a day would have a way to act, wait, and have the
* record of what they did purged before anybody had reason to look so the floor is what makes the
* log outlive the window in which its subject would want it gone.
*
* Thirty days is not a claim that a month is enough; it is the point below which the setting stops
* being a retention policy and starts being a way to cover tracks. Longer is a choice, shorter is
* not offered.
*/
export const MIN_RETENTION_DAYS = 30
/**
* Audit log model
*
* Rows are written by `helpers/audit.ts` from API route handlers and read by `api/auditLog.ts`.
* Nothing else writes here see the table's comment in `db/schema.ts` for why that boundary is the
* feature rather than an implementation detail.
*/
class AuditLog {
/**
* Record one action.
*
* Never throws. An audit row is a record of a request that has already succeeded, and losing one is
* not a reason to fail the request that was the point the same trade `pageHistory.record` makes.
* A failure is logged at error level rather than warn, because unlike a missing history entry a
* missing audit entry is a gap in a record somebody is relying on.
*/
async record({
kind,
action,
actor,
meta = {}
}: {
kind: AuditKind
action: AuditAction
actor: AuditActor
meta?: Record<string, any>
}): Promise<void> {
try {
await WIKI.db.insert(auditLogTable).values({
kind,
action,
clientIP: actor.ip,
userId: actor.id,
/*
Sanitized here rather than at each caller, so that every path in the route helper and the
handful of auth events that record themselves goes through the same backstop.
`actor` is applied AFTER it, and last, so a route cannot overwrite it by accident with a
meta key of its own and the copy of the account cannot be redacted by a key name.
*/
meta: {
...sanitizeMeta(meta),
actor: { name: actor.name, email: actor.email, ip: actor.ip }
}
})
} catch (err: any) {
WIKI.logger.error(`Failed to record audit entry ${kind}/${action}: ${err.message}`)
}
}
/**
* The filters as one WHERE, or nothing at all when none were given.
*
* Shared by `list` and `stream` rather than written twice: an export that did not match the screen
* it was taken from would be the worst kind of wrong, since nothing about the file would say so.
*/
whereFor(filters: AuditLogFilters) {
const conditions = []
if (filters.userIds && filters.userIds.length > 0) {
// -> `inArray` even for one, so the single and the many cases are the same query
conditions.push(inArray(auditLogTable.userId, filters.userIds))
}
if (filters.kind) {
conditions.push(eq(auditLogTable.kind, filters.kind))
}
if (filters.action) {
conditions.push(eq(auditLogTable.action, filters.action))
}
if (filters.from) {
conditions.push(gte(auditLogTable.ts, new Date(filters.from)))
}
if (filters.to) {
conditions.push(lte(auditLogTable.ts, new Date(filters.to)))
}
return conditions.length > 0 ? and(...conditions) : undefined
}
/**
* Every entry matching the filters, newest first, a batch at a time.
*
* A generator rather than an array because this backs the export, and the whole point of exporting
* an audit log is that it is long: an instance with a year of history can hold millions of rows,
* and materialising those to answer one request would take the wiki down with it. The caller
* streams what this yields straight to the response.
*
* Paged by keyset "older than the last row I sent" rather than by OFFSET, which re-walks and
* discards everything before it on each batch and turns one export into a quadratic scan. `id`
* breaks ties, since two entries can share a timestamp and an unstable order would drop or repeat
* rows across batch boundaries.
*/
async *stream(filters: AuditLogFilters, batchSize = 1000): AsyncGenerator<AuditEntry> {
const where = this.whereFor(filters)
let cursor: { ts: Date; id: string } | null = null
for (;;) {
const keyset = cursor
? or(
lt(auditLogTable.ts, cursor.ts),
and(eq(auditLogTable.ts, cursor.ts), lt(auditLogTable.id, cursor.id))
)
: undefined
const rows = (await WIKI.db
.select()
.from(auditLogTable)
.where(keyset ? and(where, keyset) : where)
.orderBy(desc(auditLogTable.ts), desc(auditLogTable.id))
.limit(batchSize)) as AuditEntry[]
for (const row of rows) {
yield row
}
// -> A short batch is the last one; a full one may or may not be, so it costs one empty query
if (rows.length < batchSize) {
return
}
const last = rows[rows.length - 1]!
cursor = { ts: last.ts, id: last.id }
}
}
/**
* One page of the log, newest first.
*
* No join to `users`: every row already carries the name and email the account had at the time, and
* that is what should be shown. Reading the current name instead would quietly rewrite history
* every time somebody was renamed, and would show nothing at all once they were deleted.
*
* @param filters What to narrow by
* @param page 1-based page number
* @param limit Rows per page
*/
async list(filters: AuditLogFilters, page: number, limit: number): Promise<AuditLogPage> {
const where = this.whereFor(filters)
const [totals, entries] = await Promise.all([
WIKI.db.select({ total: count() }).from(auditLogTable).where(where),
WIKI.db
.select()
.from(auditLogTable)
.where(where)
.orderBy(desc(auditLogTable.ts))
.limit(limit)
.offset((page - 1) * limit)
])
return {
total: totals[0]?.total ?? 0,
entries: entries as AuditEntry[]
}
}
/**
* How long rows are kept, in days. Zero means forever.
*
* The floor is applied on the way OUT as well as on the way in. `api/auditLog.ts` refuses to store
* anything under it, so a value below it can only have arrived another way a hand-edited
* `config.yml`, a direct write to the settings table and honouring it there would leave the one
* route round the rule that the rule exists to close. Anything between 1 and the floor is read as
* the floor rather than as itself; a value that is not a whole number of days at all falls back to
* the default.
*/
retentionDays(): number {
const configured = WIKI.config.audit?.retentionDays
if (!Number.isInteger(configured) || configured < 0) {
return DEFAULT_RETENTION_DAYS
}
if (configured === 0) {
return 0
}
return Math.max(configured, MIN_RETENTION_DAYS)
}
/**
* Delete rows older than the configured retention.
*
* @returns How many rows went, or 0 when retention is off
*/
async purge(): Promise<number> {
const days = this.retentionDays()
if (days < 1) {
return 0
}
// -> `Instant` takes exact time units only, so days are expressed as hours
const cutoff = Temporal.Now.instant().subtract({ hours: days * 24 })
const deleted = await WIKI.db
.delete(auditLogTable)
.where(lt(auditLogTable.ts, new Date(cutoff.epochMilliseconds)))
.returning({ id: auditLogTable.id })
return deleted.length
}
/** How many rows the log holds, for the retention card in the admin area. */
async total(): Promise<number> {
const rows = await WIKI.db.select({ total: count() }).from(auditLogTable)
return rows[0]?.total ?? 0
}
/** The instant of the oldest row, or null when the log is empty. */
async oldestEntry(): Promise<string | null> {
const rows = await WIKI.db
.select({ ts: sql<Date>`min(${auditLogTable.ts})` })
.from(auditLogTable)
const oldest = rows[0]?.ts
return oldest ? new Date(oldest).toISOString() : null
}
}
export const auditLog = new AuditLog()

@ -1,6 +1,7 @@
import { apiKeys } from './apiKeys.ts'
import { approvals } from './approvals.ts'
import { assets } from './assets.ts'
import { auditLog } from './auditLog.ts'
import { authentication } from './authentication.ts'
import { blocks } from './blocks.ts'
import { extensions } from './extensions.ts'
@ -32,6 +33,7 @@ export default {
apiKeys,
approvals,
assets,
auditLog,
authentication,
blocks,
extensions,

@ -37,6 +37,9 @@ export const SYSTEM_SCHEDULE: SystemScheduleEntry[] = [
{ task: 'checkVersion', cron: '0 0 * * *' },
{ task: 'cleanJobHistory', cron: '5 0 * * *' },
// { task: 'refreshAutocomplete', cron: '0 */6 * * *' },
// -> Daily, off the hour: the retention is expressed in days, so nothing is gained by looking
// more often than once a day
{ task: 'purgeAuditLog', cron: '20 0 * * *' },
{ task: 'purgeRateLimits', cron: '10 * * * *' },
{ task: 'updateLocales', cron: '0 0 * * *' },
// -> Every minute, and the task decides which sites are actually due: the interval is a per-site

@ -1,4 +1,4 @@
import { and, eq, inArray, ne, notInArray, sql } from 'drizzle-orm'
import { and, desc, eq, inArray, ne, notInArray, sql } from 'drizzle-orm'
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
import {
CustomError,
@ -209,11 +209,49 @@ export interface PageInput {
}
/** Who is saving, and what they are allowed to put in a page. */
/** One row of the admin dashboard's recently-edited panel. */
export interface RecentPage {
id: string
siteId: string
locale: string
path: string
title: string
updatedAt: Date
/** Whether this is the page appearing for the first time rather than a later edit of it. */
isNew: boolean
/**
* Where the page is, as a path on its own site locale prefix included only where that site's
* settings put one there.
*/
url: string
/**
* The host that site answers on, so a caller looking at one site can still link to a page on
* another. Null for the catch-all site, which has no host of its own to name.
*/
hostname: string | null
/** Who wrote the version that stands. Null once that account is deleted. */
authorName: string | null
}
export interface PageActor {
id: string
permissions: string[]
}
/**
* A page as it stands after a change, together with the history version that change produced.
*
* The version is returned rather than kept to itself because the audit log references it: an audit
* entry says a page was edited and points at the `pageHistory` row holding what the edit actually
* was, instead of copying the before and after into a second table. Null when history could not be
* recorded `pageHistory.record` swallows its own failures, since losing a version is not a reason
* to fail the edit.
*/
export interface PageChange {
page: Page
versionId: string | null
}
function hasPermission(actor: PageActor, permission: string): boolean {
return actor.permissions.includes('manage:system') || actor.permissions.includes(permission)
}
@ -709,6 +747,74 @@ class Pages {
* @param withPassword Whether to include the password value. For whoever may edit the page not for
* a reader who just entered it, who needs it no more after that.
*/
/**
* The pages touched most recently, newest first what the admin dashboard's panel is built from.
*
* Across every site, like the rest of that dashboard: it answers "what has been happening here",
* and an instance-wide question should not be filtered to whichever site the admin area happens to
* be pointed at.
*
* Ordered by `updatedAt`, which covers both halves of "edited or created": a page's creation sets
* it and every save moves it, so one column is the whole of the answer.
*/
async getRecentlyEdited({ limit = 10 }: { limit?: number } = {}): Promise<RecentPage[]> {
const rows = await WIKI.db
.select({
id: pagesTable.id,
siteId: pagesTable.siteId,
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
createdAt: pagesTable.createdAt,
updatedAt: pagesTable.updatedAt,
authorName: usersTable.name
})
.from(pagesTable)
// -> Left, so a page whose author has since been deleted is still listed, without a name
.leftJoin(usersTable, eq(usersTable.id, pagesTable.authorId))
.orderBy(desc(pagesTable.updatedAt))
.limit(limit)
return rows.map((row) => ({
id: row.id,
siteId: row.siteId,
locale: row.locale,
path: row.path,
title: row.title,
updatedAt: row.updatedAt,
/*
Creating a page sets both stamps to the same moment and every later save moves only
`updatedAt`, so equality is what tells the two apart. Compared in milliseconds because these
are `Date`s, which have no `valueOf` ordering worth relying on for equality.
*/
isNew: row.createdAt.getTime() === row.updatedAt.getTime(),
url: this.urlFor(row.siteId, row.locale, row.path),
// -> `*` is the catch-all rather than a host; a link to it is whatever host you are already on
hostname:
WIKI.sites[row.siteId]?.hostname && WIKI.sites[row.siteId].hostname !== '*'
? WIKI.sites[row.siteId].hostname
: null,
authorName: row.authorName
}))
}
/**
* Where a page lives, as a path.
*
* Built here rather than by the caller because the rule is per SITE a site brackets its URLs by
* locale or it does not and a client looking at one site has no way to know how another one is
* configured. Mirrors `localeUrlPrefix` in the frontend's site store and the redirect in
* `index.ts`, which is what corrects a URL that arrives without the prefix.
*/
urlFor(siteId: string, locale: string, path: string): string {
const locales = WIKI.sites[siteId]?.config?.locales
const prefix =
locales?.forcePrefix || locale !== locales?.primary
? `/${WIKI.models.locales.shortCodeFor(locale)}`
: ''
return `${prefix}/${path}`
}
async getPage({
siteId,
id,
@ -846,7 +952,7 @@ class Pages {
*
* @param actor Who is saving it. Their permissions decide what survives sanitizing.
*/
async createPage(siteId: string, input: PageInput, actor: PageActor): Promise<Page> {
async createPage(siteId: string, input: PageInput, actor: PageActor): Promise<PageChange> {
if (!WIKI.sites[siteId]) {
throw new CustomError('pageInvalidSite', 'This site does not exist.', 404)
}
@ -970,7 +1076,7 @@ class Pages {
throw err
}
await WIKI.models.pageHistory.record({
const versionId = await WIKI.models.pageHistory.record({
siteId,
pageId: page.id,
action: 'created',
@ -991,7 +1097,7 @@ class Pages {
metadata: { title: page.title, description: page.description, editor }
})
return (await this.getPage({ siteId, id: page.id })) as Page
return { page: (await this.getPage({ siteId, id: page.id })) as Page, versionId }
}
/**
@ -1002,7 +1108,7 @@ class Pages {
id: string,
patch: Partial<PageInput>,
actor: PageActor
): Promise<Page | null> {
): Promise<PageChange | null> {
const results = await WIKI.db
.select()
.from(pagesTable)
@ -1121,7 +1227,7 @@ class Pages {
const updated = (await this.getPage({ siteId, id })) as Page
await WIKI.models.pageHistory.record({
const versionId = await WIKI.models.pageHistory.record({
siteId,
pageId: id,
action: 'updated',
@ -1162,7 +1268,7 @@ class Pages {
metadata: { title: updated.title, description: updated.description }
})
return updated
return { page: updated, versionId }
}
/**
@ -1173,7 +1279,7 @@ class Pages {
id: string,
{ path, locale, title }: { path: string; locale?: string; title?: string },
actor: PageActor
): Promise<Page | null> {
): Promise<PageChange | null> {
// -> With the source, which the move itself does not need: it is what the copy kept by a storage
// target is rewritten from once the page has landed at its new path
const page = await this.getPage({ siteId, id, withContent: true })
@ -1190,7 +1296,8 @@ class Pages {
const newLocale = locale || page.locale
const isRelocated = newPath !== page.path || newLocale !== page.locale
if (!isRelocated && (title === undefined || title === page.title)) {
return page
// -> Nothing moved and nothing was renamed, so there is no version recording a change either
return { page, versionId: null }
}
if (isRelocated) {
@ -1259,7 +1366,7 @@ class Pages {
// -> Recorded as its own kind of change rather than an edit: a move is what breaks inbound links,
// and a history list has to be able to say so
await WIKI.models.pageHistory.record({
const versionId = await WIKI.models.pageHistory.record({
siteId,
pageId: id,
action: 'moved',
@ -1293,21 +1400,22 @@ class Pages {
siteId,
authorId: actor.id
})
return moved
return { page: moved, versionId }
}
/**
* Delete a page and its tree entry.
*
* @returns Whether a page was deleted
* @returns The page as it last stood and the version recording its deletion, or null when there
* was no such page
*/
async deletePage(siteId: string, id: string, actor: PageActor): Promise<boolean> {
async deletePage(siteId: string, id: string, actor: PageActor): Promise<PageChange | null> {
const page = await this.getPage({ siteId, id })
if (!page) {
return false
return null
}
// -> Before the row goes, and this version is what recovering the page would be built from
await WIKI.models.pageHistory.record({
const versionId = await WIKI.models.pageHistory.record({
siteId,
pageId: id,
action: 'deleted',
@ -1335,7 +1443,7 @@ class Pages {
siteId,
authorId: actor.id
})
return true
return { page, versionId }
}
/**
@ -1681,7 +1789,7 @@ class Pages {
// -> The one difference between the two directions, and deliberately the only one: a page that is
// there is *saved*, through the same method an editor saves through, so it gets a history entry
// and a re-render and a mirrored copy without any of that being reimplemented here
const page = existing[0]
const change = existing[0]
? await this.updatePage(
siteId,
existing[0].id,
@ -1713,9 +1821,10 @@ class Pages {
actor
)
// -> Only if the page went away between the two statements above
if (!page) {
if (!change) {
return null
}
const page = change.page
// -> A restore should not report every page as written today. Applied after the fact because the
// two dates are not something an API client may set, only something a file can carry back.

@ -51,6 +51,12 @@ class Settings {
isEnabled: false
}
},
{
key: 'audit',
value: {
retentionDays: 90
}
},
{
key: 'auth',
value: {

@ -1515,6 +1515,24 @@ class Users {
}
})
/*
The one audit entry not written from a route handler, and for the same reason the stamp above
is not: every way of signing in local, a provider, a passkey, and the 2FA and forced password
change continuations converges here, and recording it at each of those routes instead would
be six copies of one event that would drift apart.
A login that FAILED is deliberately not recorded anywhere. This endpoint is open to whoever can
reach the wiki, so a credential-stuffing run would otherwise be able to fill the table from the
outside; `models/rateLimits.ts` is what answers that, and the wiki's own log is where a refused
attempt shows up.
*/
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'login',
actor: { id: user.id, name: user.name, email: user.email, ip: context.ip ?? '' },
meta: { strategyId, siteId: context.siteId ?? null }
})
return {
authenticated: true,
nextAction: 'redirect',
@ -1741,6 +1759,15 @@ class Users {
user.auth[strategyId].mustChangePwd = false
await WIKI.db.update(usersTable).set({ auth: user.auth }).where(eq(usersTable.id, user.id))
// -> Recorded separately from the `login` the call below writes: two things happened, and a
// password that a login insisted be changed is the one worth being able to find on its own
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'forcedPasswordChange',
actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' },
meta: { siteId, strategyId }
})
return this.afterLoginChecks(
user,
strategyId,
@ -1853,6 +1880,19 @@ class Users {
strategyId: strategy.id
})
/*
Called from both of the paths below rather than here, because the account is undone again if
the verification email cannot be sent and an audit entry for an account that no longer
exists, pointing at a deleted row, is worse than no entry.
*/
const recordRegistration = () =>
WIKI.models.auditLog.record({
kind: 'auth',
action: 'register',
actor: { id: userId, name: name.trim(), email: address, ip: ip ?? '' },
meta: { siteId, strategyId: strategy.id, mustVerify }
})
if (mustVerify) {
const token = await this.generateToken({
kind: 'verifyEmail',
@ -1887,6 +1927,7 @@ class Users {
WIKI.models.flags.authDebug(
`Registered user ${userId} <${address}> on site ${siteId} from ${ip}, pending email verification`
)
await recordRegistration()
return {
nextAction: 'verifyEmail',
redirect: '/'
@ -1912,6 +1953,7 @@ class Users {
WIKI.models.flags.authDebug(
`Registered user ${userId} <${address}> on site ${siteId} from ${ip}, signing them in`
)
await recordRegistration()
return this.afterLoginChecks(user, strategy.id, { ip, siteId }, {}, req)
}
@ -1963,7 +2005,7 @@ class Users {
*
* @throws `ERR_INVALID_VALIDATION_TOKEN`, `ERR_EXPIRED_VALIDATION_TOKEN`, `ERR_INVALID_USER`
*/
async verifyUserEmail(token: string): Promise<void> {
async verifyUserEmail(token: string, ip?: string): Promise<void> {
const { user } = await this.validateToken({ kind: 'verifyEmail', token })
if (!user) {
throw new Error('ERR_INVALID_USER')
@ -1975,6 +2017,12 @@ class Users {
.where(eq(usersTable.id, user.id))
}
WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> confirmed their email address`)
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'verifyEmail',
actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' },
meta: {}
})
}
/**
@ -2045,6 +2093,17 @@ class Users {
WIKI.models.flags.authDebug(
`Password reset requested from ${ip} for user ${user.id} <${user.email}>, link sent`
)
/*
Only when a link was actually sent. The early return above covers an address nobody holds, and
recording those would turn an endpoint open to the internet into a way of writing arbitrary
addresses into the audit log.
*/
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'requestPasswordReset',
actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' },
meta: { siteId, strategyId: strategy.id }
})
}
/**
@ -2065,10 +2124,12 @@ class Users {
*/
async resetPassword({
token,
newPassword
newPassword,
ip
}: {
token: string
newPassword: string
ip?: string
}): Promise<void> {
if (!newPassword || newPassword.length < 8) {
throw new Error('ERR_PASSWORD_TOO_SHORT')
@ -2092,6 +2153,12 @@ class Users {
.set({ auth, isVerified: true, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> reset their password`)
await WIKI.models.auditLog.record({
kind: 'auth',
action: 'resetPassword',
actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' },
meta: { strategyId }
})
}
updateSession(user: any, req: any): void {

@ -0,0 +1,13 @@
export async function task(): Promise<void> {
WIKI.logger.info('Purging expired audit log entries...')
try {
const purged = await WIKI.models.auditLog.purge()
WIKI.logger.info(`Purged ${purged} expired audit log entries: [ COMPLETED ]`)
} catch (err: any) {
WIKI.logger.error('Purging expired audit log entries: [ 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.
274 icons.
277 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -53,11 +53,13 @@ export const BUNDLED_ICONS = {
"la:ellipsis-h": {"body":"<path fill=\"currentColor\" d=\"M6 14a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m10 0a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m10 0a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:ellipsis-v": {"body":"<path fill=\"currentColor\" d=\"M16 6a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:envelope": {"body":"<path fill=\"currentColor\" d=\"M3 8v18h26V8zm4.313 2h17.375L16 15.781zM5 10.875l10.438 6.969l.562.343l.563-.343L27 10.875V24H5z\"/>","width":32,"height":32},
"la:eraser": {"body":"<path fill=\"currentColor\" d=\"M18.906 4.094c-.804 0-1.64.273-2.281.843v.032L16.594 5L4.906 16.594c-1.21 1.21-1.203 3.183-.062 4.468l.031.032h.031l6 6c1.211 1.21 3.184 1.203 4.469.062v-.031L27 15.5c1.266-1.266 1.305-3.29.094-4.5l-6-6a3.06 3.06 0 0 0-2.188-.906m-.031 2.031c.32 0 .617.086.813.281l6 6c.386.387.44 1.153-.094 1.688l-5.032 5.031l-7.656-7.656l5.063-5.031l.031-.032c.254-.21.57-.281.875-.281m-7.406 6.781l7.656 7.656l-5.094 5.094c-.011.008-.02.024-.031.032c-.516.43-1.309.378-1.688 0L6.345 19.75c-.016-.02-.016-.043-.032-.063c-.41-.515-.375-1.312 0-1.687z\"/>","width":32,"height":32},
"la:exclamation-triangle": {"body":"<path fill=\"currentColor\" d=\"m16 3.219l-.875 1.5l-12 20.781l-.844 1.5H29.72l-.844-1.5l-12-20.781zm0 4L26.25 25H5.75zM15 14v6h2v-6zm0 7v2h2v-2z\"/>","width":32,"height":32},
"la:external-link-alt": {"body":"<path fill=\"currentColor\" d=\"M18 5v2h5.563L11.28 19.281l1.438 1.438L25 8.437V14h2V5zM5 9v18h18V14l-2 2v9H7V11h9l2-2z\"/>","width":32,"height":32},
"la:external-link-square-alt": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm6 3v2h5.563L9.28 21.281l1.438 1.438L20 13.437V19h2v-9z\"/>","width":32,"height":32},
"la:eye": {"body":"<path fill=\"currentColor\" d=\"M16 8C7.664 8 1.25 15.344 1.25 15.344L.656 16l.594.656s5.848 6.668 13.625 7.282c.371.046.742.062 1.125.062s.754-.016 1.125-.063c7.777-.613 13.625-7.28 13.625-7.28l.594-.657l-.594-.656S24.336 8 16 8m0 2c2.203 0 4.234.602 6 1.406A6.9 6.9 0 0 1 23 15a6.995 6.995 0 0 1-6.219 6.969c-.02.004-.043-.004-.062 0c-.239.011-.477.031-.719.031c-.266 0-.523-.016-.781-.031A6.995 6.995 0 0 1 9 15c0-1.305.352-2.52.969-3.563h-.031C11.717 10.617 13.773 10 16 10m0 2a3 3 0 1 0 .002 6.002A3 3 0 0 0 16 12m-8.75.938A9 9 0 0 0 7 15c0 1.754.5 3.395 1.375 4.781A23.2 23.2 0 0 1 3.531 16a24 24 0 0 1 3.719-3.063zm17.5 0A24 24 0 0 1 28.469 16a23.2 23.2 0 0 1-4.844 3.781A8.93 8.93 0 0 0 25 15c0-.715-.094-1.398-.25-2.063z\"/>","width":32,"height":32},
"la:file-alt": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.594l-.281-.313l-6-6L19.406 3zm2 2h10v6h6v16H8zm12 1.438L22.563 9H20zM11 13v2h10v-2zm0 4v2h10v-2zm0 4v2h10v-2z\"/>","width":32,"height":32},
"la:file-download": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.6l-.3-.3l-6-6l-.3-.3zm2 2h10v6h6v16H8zm12 1.4L22.6 9H20zM15 13v5h-3l4 4l4-4h-3v-5zm-3 10v2h8v-2z\"/>","width":32,"height":32},
"la:file-export": {"body":"<path fill=\"currentColor\" d=\"M6 4v24h20v-8l-2 2v4H8V6h16v4l2 2V4zm16.406 7L21 12.406L23.563 15h-9.657v2h9.656L21 19.594L22.406 21l4.313-4.281l.687-.719l-.687-.719z\"/>","width":32,"height":32},
"la:file-image": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.594l-.281-.313l-6-6L19.406 3zm2 2h10v6h6v16H8zm12 1.438L22.563 9H20zM21.094 14c-.551 0-1 .45-1 1s.449 1 1 1s1-.45 1-1s-.45-1-1-1M14 15.594l-.719.687l-4 4l1.438 1.438L14 18.437l2.281 2.282l.719.687l.719-.687L19 19.437l2.281 2.282l1.438-1.438l-3-3l-.719-.687l-.719.687L17 18.563l-2.281-2.282z\"/>","width":32,"height":32},
"la:file-import": {"body":"<path fill=\"currentColor\" d=\"M6 4v24h20v-9h-2v7H8V6h16v7h2V4zm11.5 7l-4.313 4.281L12.5 16l.688.719L17.5 21l1.406-1.406L16.313 17H28v-2H16.312l2.594-2.594z\"/>","width":32,"height":32},
@ -74,6 +76,7 @@ export const BUNDLED_ICONS = {
"la:heart": {"body":"<path fill=\"currentColor\" d=\"M9.5 5C5.363 5 2 8.402 2 12.5c0 1.43.648 2.668 1.25 3.563a9.3 9.3 0 0 0 1.219 1.468L15.28 28.375l.719.719l.719-.719L27.53 17.531S30 15.355 30 12.5C30 8.402 26.637 5 22.5 5c-3.434 0-5.645 2.066-6.5 2.938C15.145 7.066 12.934 5 9.5 5m0 2c2.988 0 5.75 2.906 5.75 2.906l.75.844l.75-.844S19.512 7 22.5 7c3.043 0 5.5 2.496 5.5 5.5c0 1.543-1.875 3.625-1.875 3.625L16 26.25L5.875 16.125s-.484-.465-.969-1.188C4.422 14.216 4 13.274 4 12.5C4 9.496 6.457 7 9.5 7\"/>","width":32,"height":32},
"la:history": {"body":"<path fill=\"currentColor\" d=\"M16 4A11.99 11.99 0 0 0 6 9.344V6H4v7h7v-2H7.375C9.102 8.02 12.297 6 16 6c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16H4c0 6.617 5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m-1 4v9h7v-2h-5V8z\"/>","width":32,"height":32},
"la:home": {"body":"<path fill=\"currentColor\" d=\"m16 2.594l-.719.687l-13 13L3.72 17.72L5 16.437V28h9V18h4v10h9V16.437l1.281 1.282l1.438-1.438l-13-13zm0 2.844l9 9V26h-5V16h-8v10H7V14.437z\"/>","width":32,"height":32},
"la:hourglass-half": {"body":"<path fill=\"currentColor\" d=\"M7 4v2h2v4a7 7 0 0 0 3.406 6A7 7 0 0 0 9 22v4H7v2h18v-2h-2v-4a7 7 0 0 0-3.406-6A7 7 0 0 0 23 10V6h2V4zm4 2h10v4c0 2.773-2.227 5-5 5s-5-2.227-5-5zm1.156 5c.446 1.723 1.98 3 3.844 3s3.398-1.277 3.844-3zM16 17c2.773 0 5 2.227 5 5v4h-1c0-2.21-1.79-4-4-4s-4 1.79-4 4h-1v-4c0-2.773 2.227-5 5-5\"/>","width":32,"height":32},
"la:icons": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h8v8H7zm10 0h8v8h-8zm-6 2l-3 4h6zm8 0v4h4V9zM7 17h8v8H7zm10 0h8v8h-8zm4 1l-2 3l2 3l2-3zm-10 1a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:id-card": {"body":"<path fill=\"currentColor\" d=\"M5 6C3.355 6 2 7.355 2 9v14c0 1.645 1.355 3 3 3h22c1.645 0 3-1.355 3-3V9c0-1.645-1.355-3-3-3zm0 2h22c.566 0 1 .434 1 1v14c0 .566-.434 1-1 1H5c-.566 0-1-.434-1-1V9c0-.566.434-1 1-1m6 2c-2.2 0-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5.04 5.04 0 0 0 6 21h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.04 5.04 0 0 0-2.219-4.156C14.523 16.117 15 15.114 15 14c0-2.2-1.8-4-4-4m7 1v2h8v-2zm-7 1c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m7 3v2h8v-2zm0 4v2h5v-2z\"/>","width":32,"height":32},
"la:image": {"body":"<path fill=\"currentColor\" d=\"M2 5v22h28V5zm2 2h24v13.906l-5.281-5.312l-.719-.719l-4.531 4.531l-5.75-5.812l-.719-.719l-7 7zm20 2a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m-13 6.719L20.188 25H4v-2.281zm11 2l6 6V25h-4.969l-4.156-4.188z\"/>","width":32,"height":32},

@ -0,0 +1,123 @@
<template>
<w-dialog v-model="dialogVisible" max-width="700px" @hide="onDialogHide">
<w-card style="min-width: 500px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-event-log.svg" size="sm" class="mr-2" />
<span>{{ actionLabel }}</span>
</w-card-section>
<w-card-section>
<w-list separator dense>
<w-item>
<w-item-section>
<w-item-label caption>{{ t('admin.audit.field.timestamp') }}</w-item-label>
<!--
Named rather than left implied. The list shows these times in the reader's own zone
without saying so, which is fine while scanning; on the record of a single event it
matters, since two administrators reading the same entry from different zones would
otherwise each take their own rendering for the moment it happened.
-->
<w-item-label>
{{ userStore.formatDateTime(t, entry.ts) }}
<span class="text-grey">({{ userStore.timezoneId() }})</span>
</w-item-label>
</w-item-section>
</w-item>
<w-item>
<w-item-section>
<w-item-label caption>{{ t('admin.audit.field.user') }}</w-item-label>
<!--
From `meta.actor`, never from the users table: this is who the account was at the
time. An entry whose account has since been deleted still reads correctly, and is
marked as such rather than silently losing its name.
-->
<w-item-label>
{{ entry.meta?.actor?.name || t('admin.audit.anonymous') }}
<span class="text-grey" v-if="entry.meta?.actor?.email">
&lt;{{ entry.meta.actor.email }}&gt;
</span>
</w-item-label>
<w-item-label caption v-if="!entry.userId && entry.meta?.actor?.email">
{{ t('admin.audit.accountGone') }}
</w-item-label>
</w-item-section>
</w-item>
<w-item>
<w-item-section>
<w-item-label caption>{{ t('admin.audit.field.clientIP') }}</w-item-label>
<w-item-label class="font-mono">{{ entry.clientIP || '---' }}</w-item-label>
</w-item-section>
</w-item>
<w-item>
<w-item-section>
<w-item-label caption>{{ t('admin.audit.field.action') }}</w-item-label>
<w-item-label class="font-mono">{{ entry.kind }} / {{ entry.action }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
<div class="text-caption text-grey mt-4 mb-1">{{ t('admin.audit.field.meta') }}</div>
<!--
The whole of `meta`, as it is stored. A formatted dump rather than a field list because
what an entry carries depends entirely on what it records a page edit names a history
version, a group change carries the permission set and inventing a layout per action
would be sixty layouts.
-->
<pre
class="overflow-x-auto rounded bg-black/5 p-3 text-caption dark:bg-white/5"><code>{{ formattedMeta }}</code></pre>
</w-card-section>
<w-card-actions class="card-actions">
<w-space />
<w-btn
unelevated
:label="t(`common.actions.close`)"
color="primary"
padding="xs md"
@click="onDialogCancel" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { useUserStore } from '@/stores/user'
// PROPS
const props = defineProps({
entry: {
type: Object,
required: true
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogCancel } = useDialogComponent()
// I18N
const { t } = useI18n()
// STORES
const userStore = useUserStore()
// COMPUTED
/*
The action key doubles as its own translation key. An action added to the server without a string
to go with it falls back to the key itself, which still says what happened.
*/
const actionLabel = computed(() =>
t(`admin.audit.actions.${props.entry.action}`, props.entry.action)
)
const formattedMeta = computed(() => JSON.stringify(props.entry.meta ?? {}, null, 2))
</script>

@ -0,0 +1,161 @@
<template>
<w-menu
class="translucent-menu"
anchor="bottom right"
self="top right"
:offset="[0, 10]"
ref="menuRef"
@show="load">
<w-card style="width: 620px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-event-log.svg" left size="sm" />
<span>{{ t('admin.audit.retention') }}</span>
</w-card-section>
<w-card-section>
<div class="text-body2 text-black/60 dark:text-white/70">
{{ t('admin.audit.retentionHint') }}
</div>
<!--
Said here rather than left for somebody to discover by being refused: the options below
simply stop at 30 days, and a floor with no reason given reads as an oversight.
-->
<div class="text-body2 mt-2 text-black/60 dark:text-white/70">
{{ t('admin.audit.retentionFloorHint') }}
</div>
<div class="mt-4">
<w-select
outlined
dense
v-model="state.retentionDays"
:options="retentionOptions"
emit-value
map-options
:label="t('admin.audit.retentionPeriod')" />
</div>
<!--
What is actually in the table, so that "keep 90 days" is chosen against a real number
rather than in the abstract. Nothing is deleted by saving the daily task is what applies
it and the copy says so.
-->
<div class="text-caption text-grey mt-4">
<i18n-t keypath="admin.audit.retentionStats" tag="span">
<template #count
><strong>{{ state.total }}</strong></template
>
<template #oldest
><strong>{{
state.oldestEntry ? userStore.formatDateTime(t, state.oldestEntry) : '---'
}}</strong></template
>
</i18n-t>
</div>
</w-card-section>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="grey"
padding="xs md"
@click="close" />
<w-btn
unelevated
:label="t(`common.actions.save`)"
color="primary"
padding="xs md"
@click="save" />
</w-card-actions>
<w-inner-loading :showing="state.loading > 0" />
</w-card>
</w-menu>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { useUserStore } from '@/stores/user'
// I18N
const { t } = useI18n()
// STORES
const userStore = useUserStore()
// REFS
const menuRef = ref(null)
// DATA
const state = reactive({
retentionDays: 90,
total: 0,
oldestEntry: null,
loading: 0
})
// COMPUTED
/*
Fixed periods rather than a free number: this is a compliance decision made once, and a typo in a
days field silently throws away years of log. Zero is offered last and named for what it does.
*/
const retentionOptions = computed(() => [
{ label: t('admin.audit.retention30'), value: 30 },
{ label: t('admin.audit.retention90'), value: 90 },
{ label: t('admin.audit.retention180'), value: 180 },
{ label: t('admin.audit.retention365'), value: 365 },
{ label: t('admin.audit.retention730'), value: 730 },
{ label: t('admin.audit.retentionForever'), value: 0 }
])
// METHODS
async function load() {
state.loading++
try {
const resp = await API_CLIENT.get('audit/config').json()
state.retentionDays = resp?.retentionDays ?? 90
state.total = resp?.total ?? 0
state.oldestEntry = resp?.oldestEntry ?? null
} catch (err) {
notify({
type: 'negative',
message: t('admin.audit.retentionLoadFailed'),
caption: apiErrorMessage(err)
})
}
state.loading--
}
async function save() {
state.loading++
try {
await API_CLIENT.put('audit/config', {
json: { retentionDays: state.retentionDays }
}).json()
notify({
type: 'positive',
message: t('admin.audit.retentionSaveSuccess')
})
close()
} catch (err) {
notify({
type: 'negative',
message: t('admin.audit.retentionSaveFailed'),
caption: apiErrorMessage(err)
})
}
state.loading--
}
function close() {
menuRef.value?.hide()
}
</script>

@ -748,6 +748,13 @@ const permissions = [
restrictedForSystem: true,
disabled: false
},
{
permission: 'read:audit',
hint: 'Can read the audit log, i.e. the record of what everybody on this wiki has done.',
warning: false,
restrictedForSystem: true,
disabled: false
},
{
permission: 'manage:navigation',
hint: 'Can manage site navigation',

@ -379,11 +379,23 @@ const hasValue = computed(() => String(props.modelValue ?? '').length > 0)
way -- it asks the caller to pin the label up whenever there is a start adornment.
A placeholder likewise: it renders in the resting position the moment the field is empty.
And so do the date and time types, which are never visually empty: the browser draws its own format
hint (`mm/dd/yyyy, --:--`) in the resting position whatever the value is, and there is no attribute
that suppresses it. An empty one would otherwise be a label printed over a format hint.
*/
const hasLeadingAdornment = computed(() => Boolean(slots.prepend || props.prefix))
/** Types whose control paints its own format hint, so the resting position is never free. */
const SELF_LABELLING_TYPES = ['date', 'datetime-local', 'month', 'time', 'week']
const isFloating = computed(
() => hasFocus.value || hasValue.value || Boolean(props.placeholder) || hasLeadingAdornment.value
() =>
hasFocus.value ||
hasValue.value ||
Boolean(props.placeholder) ||
hasLeadingAdornment.value ||
SELF_LABELLING_TYPES.includes(props.type)
)
const floatColorClass = computed(() => {

@ -446,6 +446,21 @@
--w-input-ring-hover: var(--color-white);
}
/*
The picker button on a date or time field, in dark mode.
The browser draws it itself, as a black glyph, and gives it no colour to set -- so on a dark
field it is black on near-black and effectively invisible. A filter is the only lever there is.
`invert(0.75)` rather than a full inversion: black inverted all the way is pure white, brighter
than the field's own text, where 0.75 lands on the light grey the rest of the control uses.
Chromium and Safari only; Firefox draws no indicator for these types, so there is nothing there
for the rule to match.
*/
body.body--dark .w-input-control input::-webkit-calendar-picker-indicator {
filter: invert(0.75);
}
/*
The Material notched outline, for an outlined field that carries a label: at rest the label stands
in the middle of the field, and on focus or once there is a value it rises into the top border.

@ -775,8 +775,9 @@ There are two kinds, granted separately and checked in different places.
## Global permissions
Held site-wide, bound to no path. \`access:admin\`, \`manage:users\`, \`manage:groups\`,
\`manage:navigation\`, \`manage:theme\`, \`manage:sites\`, \`manage:system\`. That list is the whole of it.
Held site-wide, bound to no path. \`access:admin\`, \`read:users\`, \`manage:users\`, \`read:groups\`,
\`manage:groups\`, \`read:audit\`, \`manage:navigation\`, \`manage:theme\`, \`manage:sites\`,
\`manage:system\`. That list is the whole of it.
\`manage:system\` bypasses every check everywhere.

@ -285,11 +285,21 @@
</w-item-section>
</w-item>
</template>
<template v-if="userStore.can(`manage:system`)">
<!--
Every entry in this section is `manage:system`'s EXCEPT the audit log, which `read:audit`
grants on its own so the section opens on either, and the rest is nested behind the one
it actually needs. Written as two nested templates rather than a `v-if` repeated down
sixteen items, which is the same rule stated sixteen times and drifts the moment one is
added without it.
-->
<template v-if="systemSectionShown">
<w-item-label class="mt-2 text-caption text-blue-grey-4" header>{{
t('admin.nav.system')
}}</w-item-label>
<w-item to="/_admin/api" active-class="bg-primary text-white">
<w-item
to="/_admin/api"
active-class="bg-primary text-white"
v-if="userStore.can(`manage:system`)">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-rest-api.svg" />
</w-item-section>
@ -298,141 +308,139 @@
<status-light :color="adminStore.info.isApiEnabled ? `positive` : `negative`" />
</w-item-section>
</w-item>
<w-item
to="/_admin/audit"
active-class="bg-primary text-white"
disabled
v-if="flagsStore.experimental">
<w-item to="/_admin/audit" active-class="bg-primary text-white" v-if="auditIsVisible">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-event-log.svg" />
</w-item-section>
<w-item-section>{{ t('admin.audit.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/extensions" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-module.svg" />
</w-item-section>
<w-item-section>{{ t('admin.extensions.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/icons" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-spring.svg" />
</w-item-section>
<w-item-section>{{ t('admin.icons.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/instances" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-network.svg" />
</w-item-section>
<w-item-section>{{ t('admin.instances.title') }}</w-item-section>
<w-item-section side>
<w-badge
color="dark-3"
:label="adminStore.info.instancesTotal"
:class="countBadgeClass(adminStore.info.instancesTotal)" />
</w-item-section>
</w-item>
<w-item to="/_admin/mail" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-message-settings.svg" />
</w-item-section>
<w-item-section>{{ t('admin.mail.title') }}</w-item-section>
<w-item-section side>
<status-light
:color="adminStore.info.isMailConfigured ? `positive` : `warning`"
:pulse="!adminStore.info.isMailConfigured" />
</w-item-section>
</w-item>
<w-item to="/_admin/mcp" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-ai.svg" />
</w-item-section>
<w-item-section>{{ t('admin.mcp.title') }}</w-item-section>
<w-item-section side>
<status-light
:color="adminStore.info.isMCPEnabled ? `positive` : `negative`" />
</w-item-section>
</w-item>
<w-item to="/_admin/metrics" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-graph.svg" />
</w-item-section>
<w-item-section>{{ t('admin.metrics.title') }}</w-item-section>
<w-item-section side>
<status-light :color="adminStore.info.isMetricsEnabled ? `positive` : `negative`" />
</w-item-section>
</w-item>
<w-item
to="/_admin/rendering"
active-class="bg-primary text-white"
v-if="flagsStore.experimental">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-rich-text-converter.svg" />
</w-item-section>
<w-item-section>{{ t('admin.rendering.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/scheduler" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-bot.svg" />
</w-item-section>
<w-item-section>{{ t('admin.scheduler.title') }}</w-item-section>
<w-item-section side>
<status-light
:color="adminStore.info.isSchedulerHealthy ? `positive` : `warning`"
:pulse="!adminStore.info.isSchedulerHealthy" />
</w-item-section>
</w-item>
<w-item to="/_admin/search" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-find-and-replace.svg" />
</w-item-section>
<w-item-section>{{ t('admin.search.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/security" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-protect.svg" />
</w-item-section>
<w-item-section>{{ t('admin.security.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/system" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-processor.svg" />
</w-item-section>
<w-item-section>{{ t('admin.system.title') }}</w-item-section>
<w-item-section side>
<status-light :color="adminStore.isVersionLatest ? `positive` : `warning`" />
</w-item-section>
</w-item>
<w-item to="/_admin/terminal" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-linux-terminal.svg" />
</w-item-section>
<w-item-section>{{ t('admin.terminal.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/utilities" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-swiss-army-knife.svg" />
</w-item-section>
<w-item-section>{{ t('admin.utilities.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/webhooks" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-lightning-bolt.svg" />
</w-item-section>
<w-item-section>{{ t('admin.webhooks.title') }}</w-item-section>
<w-item-section side>
<w-badge
color="dark-3"
:label="adminStore.info.webhooksTotal"
:class="countBadgeClass(adminStore.info.webhooksTotal)" />
</w-item-section>
</w-item>
<w-item to="/_admin/flags" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-windsock.svg" />
</w-item-section>
<w-item-section>{{ t('admin.dev.flags.title') }}</w-item-section>
</w-item>
<template v-if="userStore.can(`manage:system`)">
<w-item to="/_admin/extensions" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-module.svg" />
</w-item-section>
<w-item-section>{{ t('admin.extensions.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/icons" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-spring.svg" />
</w-item-section>
<w-item-section>{{ t('admin.icons.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/instances" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-network.svg" />
</w-item-section>
<w-item-section>{{ t('admin.instances.title') }}</w-item-section>
<w-item-section side>
<w-badge
color="dark-3"
:label="adminStore.info.instancesTotal"
:class="countBadgeClass(adminStore.info.instancesTotal)" />
</w-item-section>
</w-item>
<w-item to="/_admin/mail" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-message-settings.svg" />
</w-item-section>
<w-item-section>{{ t('admin.mail.title') }}</w-item-section>
<w-item-section side>
<status-light
:color="adminStore.info.isMailConfigured ? `positive` : `warning`"
:pulse="!adminStore.info.isMailConfigured" />
</w-item-section>
</w-item>
<w-item to="/_admin/mcp" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-ai.svg" />
</w-item-section>
<w-item-section>{{ t('admin.mcp.title') }}</w-item-section>
<w-item-section side>
<status-light :color="adminStore.info.isMCPEnabled ? `positive` : `negative`" />
</w-item-section>
</w-item>
<w-item to="/_admin/metrics" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-graph.svg" />
</w-item-section>
<w-item-section>{{ t('admin.metrics.title') }}</w-item-section>
<w-item-section side>
<status-light
:color="adminStore.info.isMetricsEnabled ? `positive` : `negative`" />
</w-item-section>
</w-item>
<w-item
to="/_admin/rendering"
active-class="bg-primary text-white"
v-if="flagsStore.experimental">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-rich-text-converter.svg" />
</w-item-section>
<w-item-section>{{ t('admin.rendering.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/scheduler" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-bot.svg" />
</w-item-section>
<w-item-section>{{ t('admin.scheduler.title') }}</w-item-section>
<w-item-section side>
<status-light
:color="adminStore.info.isSchedulerHealthy ? `positive` : `warning`"
:pulse="!adminStore.info.isSchedulerHealthy" />
</w-item-section>
</w-item>
<w-item to="/_admin/search" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-find-and-replace.svg" />
</w-item-section>
<w-item-section>{{ t('admin.search.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/security" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-protect.svg" />
</w-item-section>
<w-item-section>{{ t('admin.security.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/system" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-processor.svg" />
</w-item-section>
<w-item-section>{{ t('admin.system.title') }}</w-item-section>
<w-item-section side>
<status-light :color="adminStore.isVersionLatest ? `positive` : `warning`" />
</w-item-section>
</w-item>
<w-item to="/_admin/terminal" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-linux-terminal.svg" />
</w-item-section>
<w-item-section>{{ t('admin.terminal.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/utilities" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-swiss-army-knife.svg" />
</w-item-section>
<w-item-section>{{ t('admin.utilities.title') }}</w-item-section>
</w-item>
<w-item to="/_admin/webhooks" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-lightning-bolt.svg" />
</w-item-section>
<w-item-section>{{ t('admin.webhooks.title') }}</w-item-section>
<w-item-section side>
<w-badge
color="dark-3"
:label="adminStore.info.webhooksTotal"
:class="countBadgeClass(adminStore.info.webhooksTotal)" />
</w-item-section>
</w-item>
<w-item to="/_admin/flags" active-class="bg-primary text-white">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-windsock.svg" />
</w-item-section>
<w-item-section>{{ t('admin.dev.flags.title') }}</w-item-section>
</w-item>
</template>
</template>
</w-list>
</w-scroll-area>
@ -593,6 +601,12 @@ const usersAreVisible = computed(() => {
const usersSectionShown = computed(() => {
return groupsAreVisible.value || usersAreVisible.value
})
const auditIsVisible = computed(() => {
return userStore.can('read:audit')
})
const systemSectionShown = computed(() => {
return userStore.can('manage:system') || auditIsVisible.value
})
const overlayIsShown = computed(() => {
return Boolean(adminStore.overlay)
})

@ -0,0 +1,629 @@
<template>
<w-page class="admin-audit">
<div class="flex flex-wrap items-center p-4">
<div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-event-log.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.audit.title') }}</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.audit.subtitle') }}
</div>
</div>
<div class="flex flex-none items-center">
<!--
Ahead of the utility buttons and fenced off from them: this is the one control here that
takes something away with it, where the three beside it read documentation, reload the
screen, or open a setting.
-->
<w-btn
class="mr-2 acrylic-btn"
flat
icon="la:file-download"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`admin.audit.export`)"
:loading="state.exporting"
@click="exportLog" />
<w-separator vertical class="mr-2 h-6 self-center" />
<w-btn
class="mr-2 acrylic-btn"
icon="la:question-circle"
flat
color="grey"
:aria-label="t(`common.actions.viewDocs`)"
:href="siteStore.docsBase + `/admin/audit`"
target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<w-btn
class="mr-2 acrylic-btn"
icon="la:redo-alt"
flat
color="secondary"
:loading="state.loading > 0"
:aria-label="t(`common.actions.refresh`)"
@click="refresh">
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
</w-btn>
<!--
Reading the log and deciding how long it is kept are different authorities: shortening the
retention destroys evidence. The endpoint behind this needs `manage:system`, so the button
is only offered to somebody who holds it.
-->
<w-btn
v-if="canManageRetention"
unelevated
icon="la:hourglass-half"
color="secondary"
:label="t(`admin.audit.retention`)">
<audit-retention-menu />
</w-btn>
</div>
</div>
<w-separator inset />
<div class="grid grid-cols-12 gap-4 p-4">
<div class="col-span-12">
<w-card>
<!--
Every child is now one control on one line, so they align on their own box rather than on
a taller neighbour's.
-->
<w-card-section class="flex flex-wrap items-center gap-3">
<!--
The user filter is a picker rather than a dropdown: an instance can have more accounts
than a select should ever hold, and the search dialog already exists for exactly this.
It carries its own label inline, since a button has no floating-label chrome to put one
in and a caption line above it would stand this column a line taller than the four
beside it.
`my-2` matches what an outlined WInput/WSelect gives its own control (see
`controlClasses` there) to leave room for the floated label. Without it a bare button
in this row bottom-aligns 8px below the fields, which is exactly what it did.
The colour is per theme because `primary-light` is a 60% primary / 40% white mix: it
reads at 6.9:1 on the dark card and 2.4:1 on the white one, where it is a pale blue on
white. Full `primary` carries the light theme, as it does for every other flat button
on a card.
-->
<div class="my-2 flex min-w-[220px] flex-1 items-center gap-2">
<w-btn
class="acrylic-btn"
flat
no-caps
icon="la:user"
:color="dark.isActive ? `primary-light` : `primary`"
:label="userFilterLabel"
@click="pickUser" />
<w-btn
v-if="state.filterUsers.length > 0"
class="acrylic-btn"
flat
dense
icon="la:times"
color="grey"
:aria-label="t('common.actions.clear')"
@click="clearUser" />
</div>
<div class="min-w-[160px] flex-1">
<w-select
outlined
dense
v-model="state.filterKind"
:options="kindOptions"
emit-value
map-options
:label="t('admin.audit.field.kind')" />
</div>
<div class="min-w-[220px] flex-1">
<w-select
outlined
dense
v-model="state.filterAction"
:options="actionOptions"
emit-value
map-options
:label="t('admin.audit.field.action')" />
</div>
<!--
`datetime-local`, so the two bounds are entered in the reader's own clock and converted
on the way out. The API takes instants; a wall-clock string would be ambiguous the
moment two administrators sat in different time zones.
-->
<div class="min-w-[200px] flex-1">
<w-input
outlined
dense
type="datetime-local"
v-model="state.filterFrom"
:label="t('admin.audit.field.from')" />
</div>
<div class="min-w-[200px] flex-1">
<w-input
outlined
dense
type="datetime-local"
v-model="state.filterTo"
:label="t('admin.audit.field.to')" />
</div>
<!-- -> Same `my-2` as the user button above, for the same reason -->
<div class="my-2 flex-none">
<w-btn
class="acrylic-btn"
flat
no-caps
icon="la:eraser"
color="grey"
:label="t('admin.audit.clearFilters')"
:disable="!hasFilters"
@click="clearFilters" />
</div>
</w-card-section>
</w-card>
</div>
<div class="col-span-12">
<w-banner
v-if="state.entries.length < 1 && state.loading < 1"
rounded
:class="dark.isActive ? `bg-dark-3 text-grey-4` : `bg-grey-2 text-grey-8`">
{{ hasFilters ? t('admin.audit.noneMatching') : t('admin.audit.none') }}
</w-banner>
<w-card v-else>
<w-table
:rows="state.entries"
:columns="headers"
row-key="id"
flat
:loading="state.loading > 0">
<template #body-cell-ts="props">
<w-td :props="props">
<div>{{ userStore.formatDateTime(t, props.value) }}</div>
<small class="text-grey">{{ relativeDate(props.value) }}</small>
</w-td>
</template>
<template #body-cell-user="props">
<w-td :props="props">
<!--
Read off `meta.actor`, which is the copy taken when the entry was written. An
account that has since been deleted keeps its name here, which is the whole reason
that copy exists.
-->
<div>
<strong>{{ props.row.meta?.actor?.name || t('admin.audit.anonymous') }}</strong>
<w-icon
v-if="!props.row.userId && props.row.meta?.actor?.email"
class="ml-1"
name="la:user-slash"
color="grey"
size="xs">
<w-tooltip>{{ t('admin.audit.accountGone') }}</w-tooltip>
</w-icon>
</div>
<small class="text-grey" v-if="props.row.meta?.actor?.email">
{{ props.row.meta.actor.email }}
</small>
</w-td>
</template>
<template #body-cell-kind="props">
<w-td :props="props">
<w-chip
square
size="sm"
dense
:color="kindStyle(props.value).color"
:text-color="kindStyle(props.value).textColor">
{{ t(`admin.audit.kinds.${props.value}`, props.value) }}
</w-chip>
</w-td>
</template>
<template #body-cell-action="props">
<w-td :props="props">
<div>{{ t(`admin.audit.actions.${props.value}`, props.value) }}</div>
<small class="text-grey font-mono">{{ props.value }}</small>
</w-td>
</template>
<template #body-cell-clientIP="props">
<w-td :props="props">
<span class="font-mono">{{ props.value || '---' }}</span>
</w-td>
</template>
<template #body-cell-details="props">
<w-td :props="props">
<w-btn
class="acrylic-btn"
flat
no-caps
icon="la:search-plus"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t('admin.audit.viewDetails')"
@click="showEntry(props.row)" />
</w-td>
</template>
</w-table>
</w-card>
<div class="mt-6 flex items-center justify-center" v-if="state.totalPages > 1">
<w-pagination
v-model="state.currentPage"
:max="state.totalPages"
:max-pages="9"
boundary-numbers
direction-links />
</div>
</div>
</div>
</w-page>
</template>
<script setup>
import { computed, onMounted, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { dialog } from '@/composables/dialog'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import { fileSave } from 'browser-fs-access'
import { relativeDate } from '@/helpers/datetime'
import { apiErrorMessage } from '@/helpers/apiError'
import AuditEntryDialog from '@/components/AuditEntryDialog.vue'
import AuditRetentionMenu from '@/components/AuditRetentionMenu.vue'
import UserSearchDialog from '@/components/UserSearchDialog.vue'
// COMPOSABLES
const dark = useDark()
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// META
useMeta({
title: t('admin.audit.title')
})
// DATA
const state = reactive({
entries: [],
/** Every action key the server records, by area — what the action filter is built from. */
actionsByKind: [],
loading: 0,
exporting: false,
/*
A list, because the picker behind it selects a set it has checkboxes. Taking only the first of
what somebody chose and labelling the button with that one name told them the other selections
had been applied when they had been thrown away.
*/
filterUsers: [],
filterKind: null,
filterAction: null,
filterFrom: '',
filterTo: '',
currentPage: 1,
pageSize: 25,
totalPages: 1
})
/*
A computed, not a plain array: the locale strings are fetched after the app mounts
(`App.vue` `applyLocale`), so a `t()` called once during `setup()` can resolve before they land
and leave the header row showing raw keys for the life of the page. Inside a computed it
re-evaluates when `setLocaleMessage` fills them in.
*/
const headers = computed(() => [
{
label: t('admin.audit.field.timestamp'),
align: 'left',
field: 'ts',
name: 'ts',
sortable: false,
style: 'width: 200px'
},
{
label: t('admin.audit.field.user'),
align: 'left',
field: 'user',
name: 'user',
sortable: false,
style: 'width: 260px'
},
{
label: t('admin.audit.field.kind'),
align: 'left',
field: 'kind',
name: 'kind',
sortable: false,
style: 'width: 110px'
},
{
label: t('admin.audit.field.action'),
align: 'left',
field: 'action',
name: 'action',
sortable: false
},
{
label: t('admin.audit.field.clientIP'),
align: 'left',
field: 'clientIP',
name: 'clientIP',
sortable: false,
style: 'width: 160px'
},
{
label: '',
align: 'right',
field: 'details',
name: 'details',
sortable: false,
style: 'width: 140px'
}
])
/*
One colour per area, so a kind is recognisable before its label is read.
The text colour travels with it rather than being a fixed `white` on the chip: amber is light
enough that white on it is around 1.9:1, so that one carries its own dark ink. Anything added here
needs the same check the badge is the only thing distinguishing five areas at a glance, and one
that cannot be read is worse than no badge.
*/
const KIND_STYLES = {
page: { color: 'blue', textColor: 'white' },
asset: { color: 'teal', textColor: 'white' },
auth: { color: 'amber', textColor: 'grey-10' },
profile: { color: 'purple', textColor: 'white' },
admin: { color: 'red', textColor: 'white' }
}
const DEFAULT_KIND_STYLE = { color: 'grey', textColor: 'white' }
// COMPUTED
const canManageRetention = computed(() => userStore.can('manage:system'))
const kindOptions = computed(() => [
{ label: t('admin.audit.allKinds'), value: null },
...state.actionsByKind.map(({ kind }) => ({
label: t(`admin.audit.kinds.${kind}`, kind),
value: kind
}))
])
/*
Narrowed by the selected kind, since an action belongs to exactly one area: picking `page` and then
`updateSite` would match nothing, and offering the pair is offering an empty result.
*/
const actionOptions = computed(() => {
const groups = state.filterKind
? state.actionsByKind.filter(({ kind }) => kind === state.filterKind)
: state.actionsByKind
return [
{ label: t('admin.audit.allActions'), value: null },
...groups
.flatMap(({ actions }) => actions)
.map((action) => ({ label: t(`admin.audit.actions.${action}`, action), value: action }))
.toSorted((a, b) => a.label.localeCompare(b.label))
]
})
/*
One name when one account is picked, a count when several are a row of names would outgrow the
button and truncate, which is the same "shows you part of what you chose" problem as before.
*/
const userFilterLabel = computed(() => {
if (state.filterUsers.length === 1) {
return t('admin.audit.userFilter', { name: state.filterUsers[0].name })
}
if (state.filterUsers.length > 1) {
return t('admin.audit.userFilterMany', { count: state.filterUsers.length })
}
return t('admin.audit.userFilter', { name: t('admin.audit.anyUser') })
})
const hasFilters = computed(
() =>
state.filterUsers.length > 0 ||
Boolean(state.filterKind) ||
Boolean(state.filterAction) ||
Boolean(state.filterFrom) ||
Boolean(state.filterTo)
)
// WATCHERS
watch(
() => [state.filterUsers, state.filterKind, state.filterAction, state.filterFrom, state.filterTo],
() => {
// -> Back to the first page: page 7 of the old filter is meaningless under the new one
state.currentPage = 1
load({ page: 1 })
}
)
watch(
() => state.currentPage,
(newValue) => {
load({ page: newValue })
}
)
/*
An action that no longer belongs to the selected kind cannot match anything, so it is dropped
rather than left showing a filter that returns nothing.
*/
watch(
() => state.filterKind,
(kind) => {
if (!kind || !state.filterAction) {
return
}
const group = state.actionsByKind.find((g) => g.kind === kind)
if (!group?.actions.includes(state.filterAction)) {
state.filterAction = null
}
}
)
// METHODS
/**
* A `datetime-local` value as an instant the API can compare against.
*
* The input gives a wall-clock string with no zone, which is the reader's own clock `Date` parses
* it in exactly that zone, so this is the conversion rather than a reinterpretation.
*/
function boundaryToInstant(value) {
if (!value) {
return null
}
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
}
/**
* The filters as the API takes them.
*
* One function for the table and the export, so a download can never quietly cover a different set
* of entries than the screen it was taken from.
*/
function filterParams() {
const from = boundaryToInstant(state.filterFrom)
const to = boundaryToInstant(state.filterTo)
return {
...(state.filterUsers.length > 0
? { userId: state.filterUsers.map((u) => u.id).join(',') }
: {}),
...(state.filterKind ? { kind: state.filterKind } : {}),
...(state.filterAction ? { action: state.filterAction } : {}),
...(from ? { from } : {}),
...(to ? { to } : {})
}
}
/**
* Download everything matching the current filters as JSONL.
*
* The whole result, not the page on screen a filtered export that stopped at 25 rows would be a
* quietly wrong file. The server streams it, so what is held here is the finished download rather
* than the query behind it.
*/
async function exportLog() {
state.exporting = true
try {
const blob = await API_CLIENT.get('audit/export', { searchParams: filterParams() }).blob()
const stamp = Temporal.Now.instant().toString({ smallestUnit: 'second' }).replaceAll(':', '-')
await fileSave(blob, {
fileName: `audit-log-${stamp}.jsonl`,
extensions: ['.jsonl']
})
} catch (err) {
// -> Dismissing the save picker is not a failure, as in the other exports on this app
if (err.name !== 'AbortError') {
notify({
type: 'negative',
message: t('admin.audit.exportFailed'),
caption: apiErrorMessage(err)
})
}
}
state.exporting = false
}
async function load({ page } = {}) {
state.loading++
loading.show()
try {
const resp = await API_CLIENT.get('audit', {
searchParams: {
...filterParams(),
page: page ?? state.currentPage ?? 1,
limit: state.pageSize
}
}).json()
state.entries = resp?.entries ?? []
state.totalPages = Math.max(1, Math.ceil((resp?.total ?? 0) / state.pageSize))
} catch (err) {
notify({
type: 'negative',
message: t('admin.audit.loadFailed'),
caption: apiErrorMessage(err)
})
}
loading.hide()
state.loading--
}
async function loadActions() {
try {
state.actionsByKind = await API_CLIENT.get('audit/actions').json()
} catch (err) {
// -> The log itself still reads; only the action filter is left empty
notify({
type: 'negative',
message: t('admin.audit.loadActionsFailed'),
caption: apiErrorMessage(err)
})
}
}
function refresh() {
load()
}
function kindStyle(kind) {
return KIND_STYLES[kind] ?? DEFAULT_KIND_STYLE
}
function pickUser() {
dialog({
component: UserSearchDialog,
componentProps: {
title: t('admin.audit.pickUserTitle')
}
}).onOk((users) => {
// -> Replaces rather than adds: the dialog opens with nothing ticked, so what comes back is the
// whole of what was just chosen
state.filterUsers = users ?? []
})
}
function clearUser() {
state.filterUsers = []
}
function clearFilters() {
state.filterUsers = []
state.filterKind = null
state.filterAction = null
state.filterFrom = ''
state.filterTo = ''
}
function showEntry(entry) {
dialog({
component: AuditEntryDialog,
componentProps: { entry }
})
}
// MOUNTED
onMounted(() => {
loadActions()
load({ page: 1 })
})
</script>

@ -7,7 +7,9 @@
src="/_assets/icons/fluent-apps-tab-animated.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.dashboard.title') }}</div>
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.dashboard.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.dashboard.subtitle') }}
</div>
@ -303,6 +305,61 @@
</w-list>
</w-card>
</div>
<div class="col-span-12 lg:col-span-6">
<w-card>
<w-card-section class="admin-dashboard-panel">
<img src="/_assets/icons/fluent-copybook.svg" />
<strong>{{ t('admin.dashboard.recentPages') }}</strong>
</w-card-section>
<w-separator />
<w-list separator>
<!--
Straight to the page itself. `url` is built by the server, because whether a path
carries a locale prefix is a per-site setting and a dashboard listing every site has no
way to know how each of them is configured.
Two kinds of link, because leaving the admin area is not the same as leaving this
wiki's host: a page on the site being browsed is a route this app can take itself, and
one on another site is a plain href to that site's own host.
-->
<w-item
v-for="pg of state.recentPages"
:key="pg.id"
clickable
:to="isCurrentSite(pg) ? pg.url : null"
:href="isCurrentSite(pg) ? null : externalPageUrl(pg)">
<w-item-section side>
<!-- -> Which of the two this was; `updatedAt` alone cannot say -->
<w-icon :name="pg.isNew ? `la:plus-circle` : `la:pen`" :color="actionColor" />
</w-item-section>
<w-item-section>
<w-item-label>{{ pg.title }}</w-item-label>
<!--
`url`, not the locale and path spelled out: the address is what a reader wants to
see under the title, and on a site that does not bracket its URLs by locale the
prefix is not part of it printing one would name a path that 404s.
-->
<w-item-label caption class="font-mono">{{ pg.url }}</w-item-label>
</w-item-section>
<w-item-section side class="text-right">
<div class="text-caption">{{ relativeDate(pg.updatedAt) }}</div>
<div class="text-caption text-grey">
{{ pg.authorName || t('admin.dashboard.recentPagesAuthorGone') }}
</div>
<!-- -> The exact moment, in the reader's own pattern and zone, behind the rough one -->
<w-tooltip anchor="center left" self="center right">
{{ userStore.formatDateTime(t, pg.updatedAt) }}
</w-tooltip>
</w-item-section>
</w-item>
<w-item v-if="state.recentPages.length < 1">
<w-item-section>
<w-item-label caption>{{ t('admin.dashboard.recentPagesNone') }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
</w-card>
</div>
</div>
</w-page>
</template>
@ -367,7 +424,8 @@ const { t } = useI18n()
const state = reactive({
loading: 0,
lastLogins: []
lastLogins: [],
recentPages: []
})
// COMPUTED
@ -431,10 +489,43 @@ async function loadLastLogins() {
}
}
// -> Same bargain as the panel beside it: its own state, its own failure
async function loadRecentPages() {
try {
state.recentPages = await API_CLIENT.get('pages/recent').json()
} catch (err) {
notify({
type: 'negative',
message: 'Failed to load the recently edited pages.',
caption: err.message
})
}
}
/**
* Whether a page belongs to the site this admin area is being browsed on.
*
* Only then can the router take the reader there every other site is a different host, and a
* route this app pushes would resolve against the wrong one.
*/
function isCurrentSite(pg) {
return !pg.hostname || pg.siteId === siteStore.id
}
/** A page on another site, as an absolute URL on that site's own host. */
function externalPageUrl(pg) {
return `${window.location.protocol}//${pg.hostname}${pg.url}`
}
/** The two panels this page fills itself, in parallel — neither waits on the other. */
function loadPanels() {
return Promise.all([loadLastLogins(), loadRecentPages()])
}
async function load() {
state.loading++
try {
await Promise.all([adminStore.fetchInfo(), adminStore.fetchSites(), loadLastLogins()])
await Promise.all([adminStore.fetchInfo(), adminStore.fetchSites(), loadPanels()])
} catch (err) {
notify({
type: 'negative',
@ -445,8 +536,8 @@ async function load() {
state.loading--
}
// -> The store is already filled by the layout; this is the one thing on the page that has to ask
onMounted(loadLastLogins)
// -> The store is already filled by the layout; these two panels are what this page has to ask for
onMounted(loadPanels)
function newSite() {
dialog({

@ -17,7 +17,7 @@
class="acrylic-btn mr-2"
flat
icon="la:broom"
color="purple"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`admin.icons.purgeCache`)"
@click="purgeCache">
<w-tooltip>{{ t('admin.icons.purgeCacheHint') }}</w-tooltip>

@ -5,7 +5,9 @@
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-language.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.locale.title') }}</div>
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.locale.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.locale.subtitle') }}
</div>
@ -15,7 +17,7 @@
class="mr-2 acrylic-btn"
flat
icon="la:cloud-download-alt"
color="purple"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`admin.locale.fetch`)"
@click="fetchLocales">
<w-tooltip>{{ t(`admin.locale.fetchHint`) }}</w-tooltip>

@ -7,7 +7,9 @@
src="/_assets/icons/fluent-find-and-replace-animated.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.search.title') }}</div>
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.search.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.search.subtitle') }}
</div>
@ -18,7 +20,7 @@
flat
icon="mdi:database-refresh"
:label="t(`admin.searchRebuildIndex`)"
color="purple"
:color="dark.isActive ? `indigo-4` : `indigo`"
@click="rebuild"
:loading="state.rebuildLoading" />
<w-separator class="mr-2" vertical />
@ -100,6 +102,7 @@
import { onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
@ -109,6 +112,10 @@ import { useSiteStore } from '@/stores/site'
import UtilCodeEditor from '@/components/UtilCodeEditor.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES
const dark = useDark()
// STORES
const siteStore = useSiteStore()

@ -39,14 +39,18 @@
:loading="state.loading > 0">
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
</w-btn>
<!--
Labelled rather than icon-only: `la:user-cog` reads as "edit a user" next to a table of
users, which is not what it opens. No `aria-label` or tooltip alongside it the label is
the accessible name now, and a tooltip repeating it is one more thing to dismiss.
-->
<w-btn
class="mr-2"
v-if="canManage"
icon="la:user-cog"
unelevated
color="secondary"
:aria-label="t(`admin.users.defaults`)">
<w-tooltip>{{ t(`admin.users.defaults`) }}</w-tooltip>
:label="t(`admin.users.defaultsButton`)">
<user-defaults-menu />
</w-btn>
<w-btn

@ -81,6 +81,7 @@ const routes = [
{ path: 'users/:id?/:section?', component: () => import('@/pages/AdminUsers.vue') },
// -> System
{ path: 'api', component: () => import('@/pages/AdminApi.vue') },
{ path: 'audit', component: () => import('@/pages/AdminAudit.vue') },
{ path: 'extensions', component: () => import('@/pages/AdminExtensions.vue') },
{ path: 'icons', component: () => import('@/pages/AdminIcons.vue') },
{ path: 'instances', component: () => import('@/pages/AdminInstances.vue') },

@ -30,6 +30,29 @@ function formatDatePart(zoned, dateFormat) {
}
}
/**
* The zone this user's clock is in: their stored preference where it is still a real zone, and this
* browser's otherwise.
*
* A preference can outlive the zone it names the IANA database retires and renames them and an
* empty one is the normal state for an account that never chose. Either way a table full of dates
* must not throw, so both fall back to where the reader actually is.
*
* @param timezone This user's stored zone, which may be empty or no longer exist.
*/
function resolveZone(timezone) {
if (!timezone) {
return Temporal.Now.timeZoneId()
}
try {
// -> The only way to ask whether a zone id is real is to use it
Temporal.Instant.fromEpochMilliseconds(0).toZonedDateTimeISO(timezone)
return timezone
} catch {
return Temporal.Now.timeZoneId()
}
}
/**
* The moment as this user's clock shows it, whatever form the API sent it in.
*
@ -43,13 +66,7 @@ function toUserZone(date, timezone) {
} else if (date instanceof Date) {
instant = date.toTemporalInstant()
}
// -> A preference set before the zone list changed, or none at all, falls back to this browser's
// zone rather than throwing in the middle of a table
try {
return instant.toZonedDateTimeISO(timezone || Temporal.Now.timeZoneId())
} catch {
return instant.toZonedDateTimeISO(Temporal.Now.timeZoneId())
}
return instant.toZonedDateTimeISO(resolveZone(timezone))
}
/**
@ -218,6 +235,16 @@ export const useUserStore = defineStore('user', {
time: formatTimePart(zoned, this.timeFormat)
})
},
/**
* The IANA zone every date on screen is rendered in this user's preference, or this browser's
* where they have none or theirs no longer exists.
*
* Resolved rather than read off `timezone` directly, so that what is displayed as the zone is the
* one the timestamps beside it were actually converted to. The two are the same call.
*/
timezoneId() {
return resolveZone(this.timezone)
},
/**
* Format the DATE alone, in this user's pattern and zone. For a line with no room for a time, or
* where the time says nothing worth reading -- the day an update was released, say.

Loading…
Cancel
Save