refactor: migrate backend to Typescript + other modernization

This commit is contained in:
NGPixel
2026-07-25 14:09:48 +00:00
parent 6f492f0028
commit ee7a15fbd6
61 changed files with 2851 additions and 760 deletions
+1 -27
View File
@@ -1,36 +1,12 @@
# Based of https://github.com/devcontainers/images/blob/main/src/javascript-node/.devcontainer/Dockerfile
ARG VARIANT=24-bookworm
ARG VARIANT=26
FROM node:${VARIANT}
ARG USERNAME=node
ARG NPM_GLOBAL=/usr/local/share/npm-global
ENV DEBIAN_FRONTEND=noninteractive
# Add NPM global to PATH.
ENV PATH=${NPM_GLOBAL}/bin:${PATH}
RUN \
# Configure global npm install location, use group to adapt to UID/GID changes
if ! cat /etc/group | grep -e "^npm:" > /dev/null 2>&1; then groupadd -r npm; fi \
&& usermod -a -G npm ${USERNAME} \
&& umask 0002 \
&& mkdir -p ${NPM_GLOBAL} \
&& touch /usr/local/etc/npmrc \
&& chown ${USERNAME}:npm ${NPM_GLOBAL} /usr/local/etc/npmrc \
&& chmod g+s ${NPM_GLOBAL} \
&& npm config -g set prefix ${NPM_GLOBAL} \
&& su ${USERNAME} -c "npm config -g set prefix ${NPM_GLOBAL}" \
# Install eslint
&& su ${USERNAME} -c "umask 0002 && npm install -g eslint" \
&& npm cache clean --force > /dev/null 2>&1
# Enable PNPM
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable \
&& corepack prepare pnpm@latest --activate
EXPOSE 3000
# Install the packages we need
@@ -50,8 +26,6 @@ RUN apt-get update && apt-get install -qy \
# avoid million NPM install messages
ENV npm_config_loglevel=warn
# allow installing when the main user is root
ENV npm_config_unsafe_perm=true
# disable NPM funding messages
ENV npm_config_fund=false
+1 -1
View File
@@ -17,7 +17,7 @@ cd ../frontend
npm install
cd ../blocks
npm install
npm build
npm run build
cd ..
echo "Ready!"
+24
View File
@@ -0,0 +1,24 @@
{
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"version": "2.5.9",
"resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a",
"integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a"
},
"ghcr.io/devcontainers/features/git:1": {
"version": "1.3.8",
"resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2",
"integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "1.7.1",
"resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6",
"integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6"
},
"ghcr.io/joedmck/devcontainer-features/cloudflared:1": {
"version": "1.0.2",
"resolved": "ghcr.io/joedmck/devcontainer-features/cloudflared@sha256:128d55c58d58b2b78dcb3e60557b8ce0ffb56312b0c02bebfbffbafa122839b1",
"integrity": "sha256:128d55c58d58b2b78dcb3e60557b8ce0ffb56312b0c02bebfbffbafa122839b1"
}
}
}
+5 -2
View File
@@ -15,7 +15,6 @@
},
"extensions": [
"arcanis.vscode-zipfs",
"dbaeumer.vscode-eslint",
"eamodio.gitlens",
"Vue.volar",
"oxc.oxc-vscode",
@@ -66,7 +65,11 @@
"upgradePackages": "true"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "24"
"nodeGypDependencies": true,
"version": "26",
"npmVersion": "none",
"pnpmVersion": "none",
"nvmVersion": "latest"
},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/joedmck/devcontainer-features/cloudflared:1": {}
+5 -3
View File
@@ -7,7 +7,7 @@ services:
# Update 'VARIANT' to pick an LTS version of Node.js: 18, 16, 14, 12.
# Append -bullseye or -buster to pin to an OS version.
# Use -bullseye variants on local arm64/Apple Silicon.
VARIANT: 24-bookworm
VARIANT: 26
volumes:
- ..:/workspace
@@ -26,10 +26,12 @@ services:
# (Adding the "ports" property to this file will not forward from a Codespace.)
db:
image: postgres:17
image: postgres:18
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
# Postgres 18+ stores data in a major-version subdirectory and expects the
# volume mounted at /var/lib/postgresql (not /var/lib/postgresql/data).
- postgres-data:/var/lib/postgresql
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
+9 -1
View File
@@ -5,5 +5,13 @@
"trailingComma": "none",
"bracketSameLine": true,
"endOfLine": "lf",
"insertFinalNewline": true
"insertFinalNewline": true,
"ignorePatterns": [
"**/node_modules/**",
"backend/db/migrations/**",
"backend/locales/**",
"blocks/compiled/**",
"assets/**",
"frontend/dist/**"
]
}
+5
View File
@@ -10,5 +10,10 @@
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
}
}
+274
View File
@@ -0,0 +1,274 @@
# Wiki.js 3.x
Next-generation open source wiki. This is the **3.x development branch** — incomplete, unstable, and
with no upgrade path from 2.x. AGPL-3.0.
Three independently-installed workspaces (each has its own `package.json` / `node_modules`, there is
no root package or monorepo tooling):
| Path | What it is |
| ----------- | ------------------------------------------------------------- |
| `backend/` | Fastify REST API server + job scheduler, Drizzle on PostgreSQL |
| `frontend/` | Vue 3 / Vite / Quasar SPA |
| `blocks/` | Lit web components users embed into wiki pages |
Requires Node.js **26+** and PostgreSQL **16+**. All three workspaces are ESM (`"type": "module"`).
The backend is **TypeScript 7**; `frontend/` and `blocks/` are JavaScript. See
[TypeScript (backend)](#typescript-backend).
## Layout
### Root
- `config.yml` — instance config (copy of `config.sample.yml`). Read by the backend at boot *and* by
`frontend/vite.config.js` in dev mode to learn the proxy target port.
- `assets/`**build output** of the frontend (`vite build` writes here), plus static assets under
`assets/_assets/`. Served by the backend. Don't hand-edit.
- `dev/` — deployment/packaging artifacts: `dev/build/Dockerfile` (production image), `dev/helm/`,
`dev/packer/`, `dev/noto-emoji-build/`.
- `.devcontainer/` — VS Code dev container (app + postgres + pgAdmin via docker-compose).
- `localazy.json` — translation sync config; locale strings live in `backend/locales/`.
### `backend/`
Entry point is `backend/index.ts`, and it must be run **from the repo root** (`node backend`), not
from inside `backend/`. It boots in three phases: `preBoot()` (config → db → models → cache →
scheduler → event emitters), `initHTTPServer()` (Fastify plugins, auth, routes), `postBoot()`
(refresh locales/strategies/sites from disk & db, start scheduler).
- `api/` — REST route plugins, one file per resource (`sites.ts`, `users.ts`, `pages.ts`,
`system.ts`, `locales.ts`, `authentication.ts`), registered by `api/index.ts` under the `/_api`
prefix.
- `api/schemas/` — shared JSON Schemas registered via `app.addSchema()` and referenced from route
schemas as `{ $ref: 'Site#' }`. Register new shared schemas in `api/index.ts` *before* the routes.
- `controllers/` — non-API HTTP routes. `site.ts` serves per-site resources (logo, favicon, login
background) under `/_site`.
- `core/` — long-lived singletons: `config.ts` (yml + db-backed settings), `db.ts` (pg pool, Drizzle
instance, migrations, LISTEN/NOTIFY pubsub), `logger.ts`, `scheduler.ts` (poolifier thread pool +
postgres-backed job queue).
- `db/``schema.ts` (all Drizzle table definitions), `relations.ts`, `migrations/` (generated).
- `models/` — data-access classes over Drizzle, aggregated by `models/index.ts` and exposed as
`WIKI.models.*`. Business logic belongs here, not in route handlers. `types.ts` holds the shared
`SystemIds` passed to each model's `init()` during first-run seeding.
- `modules/` — pluggable extensions, discovered from disk. Each module is a directory with a
`definition.yml` (key, title, props/config schema) plus its implementation — e.g.
`modules/authentication/local/`.
- `tasks/simple/` — jobs run in-process by the scheduler; each exports `task()`. File name is
kebab-case, the task key is its camelCase form.
- `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal
`WIKI` global (config + logger + lazy `ensureDb()`) and dynamically imports the task.
- `base.yml` — system defaults for every config key. Do not edit as a user-facing config; it defines
the shape merged with `config.yml` and the db `settings` table.
- `helpers/` — small pure utilities (`common.ts`, `config.ts`).
- `types/` — ambient declarations: `global.d.ts` (the `WIKI` global) and `fastify.d.ts` (session +
route-permission augmentations).
- `locales/``en.json` source strings (Localazy-managed) + `metadata.js` language table (the one
remaining JavaScript file; typed by its sibling `metadata.d.ts`).
### `frontend/`
Quasar app on plain Vite (not Quasar CLI). `src/main.js` wires it up manually: router → pinia store
`boot/*` initializers → Quasar plugins → mount.
- `src/boot/` — one-time app initializers: `api.js` (creates the `ky` client with JWT refresh, exposed
as the `API_CLIENT` global), `components.js` (global components), `eventbus.js` (`EVENT_BUS` global,
mitt), `externals.js`, `i18n.js`, `monaco.js`.
- `src/router/``index.js` (router factory) and `routes.js` (the full route table; page components
are lazily imported).
- `src/layouts/``MainLayout`, `AdminLayout`, `AuthLayout`, `ProfileLayout`.
- `src/pages/` — route-level views. `Admin*.vue` are the admin area, `Profile*.vue` the user profile.
- `src/components/` — everything else: dialogs (`*Dialog.vue`), full-screen overlays
(`*Overlay.vue`), editors (`Editor*.vue`), nav/tree components.
- `src/stores/` — Pinia stores (`site`, `user`, `page`, `editor`, `admin`, `common`, `flags`).
`stores/index.js` creates the pinia instance and injects `router` into every store.
- `src/renderers/` — page content rendering pipeline: `markdown.js` plus `modules/` (katex, kroki,
plantuml, markdown-it plugins).
- `src/css/` — SCSS. `_theme.scss` holds the Quasar sass variables (wired in `vite.config.js`).
- `src/helpers/`, `src/assets/`, `public/`, `index.html`.
Path alias `@``frontend/src` (defined in `vite.config.js`; `jsconfig.json` mirrors it for the IDE).
Dev server runs on **3001** and proxies `/_api`, `/_blocks`, `/_site`, `/_thumb`, `/_user` to the
backend on **3000**, so the backend must be running too.
### `blocks/`
Self-contained Lit components. Each lives in `blocks/block-<name>/component.js` — the glob in
`rollup.config.mjs` picks up any directory matching `block-*` automatically, so a new block needs no
config change. Output goes to `blocks/compiled/`, which the backend serves statically under
`/_blocks/`. Blocks are loaded dynamically at runtime, which is why `_blocks/**` is excluded from
Vite's `dynamicImportVarsOptions`.
Blocks style themselves with `:host` / `:host-context(body.body--dark)` for dark mode and read Quasar
theme colors via CSS custom properties (`var(--q-primary)`).
## Commands
Run backend commands from `backend/`, frontend from `frontend/`, blocks from `blocks/`.
```sh
# backend
npm run dev # nodemon, restarts on any backend file change
npm run start # plain node
npm run typecheck # tsc — type check only, never emits
npm run typecheck:watch
npm run db-generate # drizzle-kit generate — after editing db/schema.ts
npm run db-up # drizzle-kit up
# frontend
npm run dev # vite dev server on :3001 (needs backend running on :3000)
npm run build # builds into ../assets — required before the backend can serve the UI
# blocks
npm run build # rollup → blocks/compiled/
```
`npx ncu -i` (`npm run ncu`) for interactive dependency updates.
The API is browsable via Swagger UI at `http://localhost:3000/_api` in a running instance. Default
admin login is `admin@example.com` / `12345678`.
## TypeScript (backend)
The backend is entirely **TypeScript 7** (the native Go compiler — `tsc` is a platform binary, not a
JS bundle). The only remaining `.js` is `locales/metadata.js`, which is Localazy-generated output and
is typed by a sibling `locales/metadata.d.ts`.
**There is no build step.** Node 26 runs `.ts` files directly by stripping types at load time, so
`node backend` and nodemon keep working unchanged as files are converted. `tsc` is used purely as a
type checker (`noEmit`) — never to produce output. Do not add a build/dist step.
Consequences of type stripping, all enforced by `backend/tsconfig.json`:
- **Relative imports must carry the real extension.** A `.ts` file importing a converted module writes
`./core/config.ts`, not `./core/config.js` and not extensionless — Node resolves the literal path.
This means converting a file requires updating the specifier in every file that imports it.
(`allowImportingTsExtensions`)
- **Only erasable syntax is allowed** — no `enum`, no `namespace`, no constructor parameter
properties, no `experimentalDecorators`. Use union types or `as const` objects instead of enums.
(`erasableSyntaxOnly`)
- **Type-only imports must say `import type`**, otherwise the import survives erasure and Node tries
to load a value that doesn't exist. (`verbatimModuleSyntax`)
`allowJs` is **off** — the backend is fully TypeScript, so a stray `.js` file would silently escape
type checking rather than be quietly tolerated. `locales/metadata.js` is the sole exception and is
resolved through its sibling `metadata.d.ts`.
`backend/types/global.d.ts` declares the ambient `WIKI` global as the `WikiGlobal` interface, wired
to the real module types (`WIKI.db` is the Drizzle instance, `WIKI.models` is `models/index.ts`, and
so on). Only `config` and `data` stay `any` — both are assembled at runtime from YAML plus a JSONB
settings table, so they have no static shape. `index.ts` and `worker.ts` build their own local `WIKI`
literal and assert it to `WikiGlobal`, since each populates the object progressively.
`backend/types/fastify.d.ts` augments Fastify: session fields (`authenticated`, `user`,
`permissions`) and the per-route `config.permissions` used by the `preHandler` permission hook.
**Three dynamic paths are extension-sensitive** and invisible to the type checker — they must be
updated by hand if the files they point at are ever renamed:
- `core/scheduler.ts``path.join(WIKI.SERVERPATH, 'worker.ts')` (the poolifier pool entry)
- `worker.ts``import('./tasks/workers/${kebabCase(job.task)}.ts')`
- `models/authentication.ts``import('../modules/authentication/${stg.module}/authentication.ts')`
`scheduler.ts` reads `tasks/simple/` filenames with `/\.[jt]s$/`, so task files are extension-agnostic.
`worker.ts` builds its own minimal `WIKI` (config + logger + lazy `ensureDb()`), but the shared
declaration types it as the full object — so worker-only code can reference members that do not
actually exist in a worker thread. Be deliberate about what you touch there.
Conventions established during the conversion, worth following in new code:
- **`catch (err: any)`** at each site rather than globally disabling `useUnknownInCatchVariables`.
Strict mode types a caught error as `unknown`, and this codebase reads `err.message` everywhere;
annotating per-site keeps the looseness visible instead of hiding it in tsconfig.
- **Per-route Fastify generics** for request shapes: `app.get<{ Params: { siteId: string } }>(...)`.
The JSON Schema stays as-is for validation and OpenAPI; the generic is what types `req.params`,
`req.body` and `req.query`.
- **Pre-existing bugs are preserved, not fixed.** Where the type checker exposed already-broken code,
it was left behaving identically behind a narrow cast plus a `FIXME:` comment explaining the real
fix. A migration should not silently change runtime behavior. Search `FIXME:` under `backend/` for
the list — they are genuine open bugs, not type-checker noise.
## Conventions
### Style, linting, formatting
**oxlint** for linting, **oxfmt** for formatting — not ESLint or Prettier (ESLint is explicitly
disabled in `.vscode/settings.json`). Both are devDependencies of `backend/` and `frontend/`.
```sh
npx oxlint # from backend/ or frontend/ — uses that dir's .oxlintrc.json
npx oxfmt <paths> # config is the repo-root .oxfmtrc.json
```
Format settings (root `.oxfmtrc.json`): no semicolons, single quotes, no trailing commas,
`bracketSameLine`, LF, final newline. 2-space indent, per `.editorconfig`.
Otherwise follow **standard JS** rules. Note that much of `frontend/` predates oxfmt and still uses
the standard-style space before parens (`function initializeRouter ()`); new and touched code should
be oxfmt-formatted, but don't reformat untouched files as drive-by changes.
Each workspace has its own `.oxlintrc.json` — the backend declares the `WIKI` global and node env;
the frontend adds the `vue` plugin and the `API_CLIENT` / `EVENT_BUS` globals. Only the `correctness`
category is an error.
Both tools handle `.ts` with no extra configuration, and the backend's oxlint config already enables
the `typescript` plugin. oxlint does not type-check — run `npm run typecheck` for that.
### Backend patterns
- **The `WIKI` global.** Set up in `index.ts`, typed in `types/global.d.ts`, available everywhere
without importing:
`WIKI.db` (Drizzle), `WIKI.models.*`, `WIKI.config`, `WIKI.logger`, `WIKI.cache`, `WIKI.scheduler`,
`WIKI.events.{inbound,outbound}` (Emittery), `WIKI.sites` / `WIKI.sitesMappings` (cached site
configs), `WIKI.ROOTPATH`, `WIKI.SERVERPATH`, `WIKI.INSTANCE_ID`.
- **Routes** are Fastify plugins: `async function routes(app) { ... }` with a default export.
- **Permissions** are declared per-route in `config.permissions`, and enforced by a single
`preHandler` hook in `index.ts`. The array is OR-ed; a nested array is AND-ed
(`permissions: ['read:sites', ['manage:pages', 'write:pages']]`). `manage:system` bypasses every
check. `@fastify/swagger`'s `transform` folds these into the OpenAPI description automatically —
so declaring them is also how they get documented.
- **Every route needs a `schema`** with `summary`, `tags`, and response schemas. `hideUntagged` is on,
so an untagged route is invisible in the API docs. Reuse `$ref` schemas from `api/schemas/`.
- **Errors** via `@fastify/sensible` helpers (`reply.notFound()`, `reply.badRequest()`,
`reply.unauthorized()`, `reply.forbidden()`). The `setErrorHandler` in `index.ts` shapes `/_api/`
failures into `{ ok, error, statusCode, message }` JSON.
- **Schema changes**: edit `db/schema.ts`, then `npm run db-generate` and commit the generated
migration. Never hand-edit an existing migration.
- Prefer **es-toolkit** over lodash on the backend.
- **Dates use the native `Temporal` API**, not luxon (which is no longer a backend dependency —
`frontend/` still uses it). `Temporal` is a global in Node 26 and is typed by the TS 7 lib, so it
needs no import. Three things to know:
- `Temporal.Instant` accepts **exact time units only**`add({ days: 1 })` throws. Since these are
all UTC instants, use `{ hours: 24 }`.
- Temporal types have no `valueOf`, so `a < b` **throws**. Compare with
`Temporal.Instant.compare(a, b)`.
- `Instant.toString()` defaults to nanosecond precision; pass
`{ smallestUnit: 'millisecond' }` for values written to postgres or compared as strings, which is
what the rest of the codebase emits.
- Converting: `date.toTemporalInstant()` from a `Date` (what drizzle returns for `timestamp`
columns), `Temporal.Instant.from(str)` for postgres-format strings (what raw `db.execute()`
returns), and `new Date(instant.epochMilliseconds)` going back the other way.
### Frontend patterns
- **Vue 3 with pug templates** (`<template lang="pug">`) in most components — check the file you're
editing rather than assuming HTML. Quasar components are auto-imported in kebab-case
(`q-btn`, `q-dialog`).
- HTTP calls go through the `ky` client, reachable as the `API_CLIENT` global (declared in the oxlint
config, so no import needed) — e.g. `await API_CLIENT.get('sites').json()`. It handles the `/_api`
prefix and JWT refresh.
- Cross-component messaging uses the `EVENT_BUS` global (mitt).
- State lives in Pinia option stores; `lodash-es` is the utility library here.
### GraphQL is being removed
An earlier iteration of 3.x used GraphQL/Apollo. 59 files under `frontend/src/` still reference
`APOLLO_CLIENT` (mostly in commented-out queries), and `blocks/block-index/` still imports a
`tree.graphql`. **All of it is deprecated** — there is no GraphQL server left in `backend/`, and
`APOLLO_CLIENT` is no longer defined as a global.
When touching such a file, port it to the REST API (`API_CLIENT` + the matching `backend/api/` route)
rather than extending the GraphQL code. If the REST endpoint doesn't exist yet, add it under
`backend/api/` following the schema + permissions conventions above.
+1 -1
View File
@@ -113,7 +113,7 @@ The server **dev** should already be available under **Servers**. If that's not
### Requirements
- PostgreSQL **16** or later
- Node.js **24.x** or later
- Node.js **26.x** or later
### Usage
@@ -1,11 +1,13 @@
import type { FastifyInstance } from 'fastify'
/**
* Authentication API Routes
*/
async function routes(app, options) {
async function routes(app: FastifyInstance) {
/**
* GET SITE AUTHENTICATION STRATEGIES
*/
app.get(
app.get<{ Params: { siteId: string }; Querystring: { visibleOnly?: boolean } }>(
'/sites/:siteId/auth/strategies',
{
schema: {
@@ -38,9 +40,9 @@ async function routes(app, options) {
}
const activeStrategies = await WIKI.models.authentication.getStrategies({ enabledOnly: true })
const siteStrategies = activeStrategies
.map((str) => {
const authModule = WIKI.data.authentication.find((m) => m.key === str.module)
const siteStr = site.config.authStrategies.find((s) => s.id === str.id) || {}
.map((str: any) => {
const authModule = WIKI.data.authentication.find((m: any) => m.key === str.module)
const siteStr = site.config.authStrategies.find((s: any) => s.id === str.id) || {}
return {
id: str.id,
displayName: str.displayName,
@@ -52,15 +54,18 @@ async function routes(app, options) {
isVisible: siteStr.isVisible ?? false
}
})
.sort((a, b) => a.order - b.order)
return req.query.visibleOnly ? siteStrategies.filter((s) => s.isVisible) : siteStrategies
.sort((a: any, b: any) => a.order - b.order)
return req.query.visibleOnly ? siteStrategies.filter((s: any) => s.isVisible) : siteStrategies
}
)
/**
* LOGIN USING USER/PASS
*/
app.put(
app.put<{
Params: { siteId: string }
Body: { strategyId: string; username?: string; password?: string }
}>(
'/sites/:siteId/auth/login',
{
schema: {
@@ -116,7 +121,7 @@ async function routes(app, options) {
ok: true,
...result
}
} catch (err) {
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
} else {
@@ -130,7 +135,10 @@ async function routes(app, options) {
/**
* CHANGE PASSWORD
*/
app.put(
app.put<{
Params: { siteId: string }
Body: { strategyId: string; continuationToken: string; newPassword: string }
}>(
'/sites/:siteId/auth/changePassword',
{
schema: {
@@ -189,7 +197,7 @@ async function routes(app, options) {
ok: true,
...result
}
} catch (err) {
} catch (err: any) {
if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message)
} else {
+467
View File
@@ -0,0 +1,467 @@
import { CustomError } from '../helpers/common.ts'
import type { FastifyInstance } from 'fastify'
import type { GroupPatch, GroupRule } from '../models/groups.ts'
interface GroupUpdateBody {
name?: string
redirectOnLogin?: string
redirectOnFirstLogin?: string
redirectOnLogout?: string
permissions?: string[]
rules?: GroupRule[]
}
/**
* Groups API Routes
*/
async function routes(app: FastifyInstance) {
/**
* LIST ALL GROUPS
*/
app.get(
'/',
{
config: {
permissions: ['read:groups', 'manage:groups']
},
schema: {
summary: 'List all groups',
tags: ['Groups'],
response: {
200: {
description: 'List of all groups',
type: 'array',
items: { $ref: 'GroupCore#' }
}
}
}
},
async () => {
return WIKI.models.groups.getAllGroups()
}
)
/**
* GET SINGLE GROUP
*/
app.get<{ Params: { groupId: string } }>(
'/:groupId',
{
config: {
permissions: ['read:groups', 'manage:groups']
},
schema: {
summary: 'Get a single group',
description: 'Returns the group with its full permissions and page rules.',
tags: ['Groups'],
params: {
type: 'object',
properties: {
groupId: {
type: 'string',
format: 'uuid'
}
},
required: ['groupId']
},
response: {
200: {
description: 'Group info',
type: 'object',
$ref: 'Group#'
}
}
}
},
async (req, reply) => {
const group = await WIKI.models.groups.getGroupById(req.params.groupId)
if (!group) {
return reply.notFound('Group does not exist.')
}
return group
}
)
/**
* UPDATE GROUP
*/
app.put<{ Params: { groupId: string }; Body: GroupUpdateBody }>(
'/:groupId',
{
config: {
permissions: ['write:groups', 'manage:groups']
},
schema: {
summary: 'Update a group',
description:
'Updates any subset of the group fields. Omitted fields are left unchanged. The permissions of the root administrators group cannot be modified.',
tags: ['Groups'],
params: {
type: 'object',
properties: {
groupId: {
type: 'string',
format: 'uuid'
}
},
required: ['groupId']
},
body: {
type: 'object',
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 255
},
redirectOnLogin: {
type: 'string',
maxLength: 255
},
redirectOnFirstLogin: {
type: 'string',
maxLength: 255
},
redirectOnLogout: {
type: 'string',
maxLength: 255
},
permissions: {
type: 'array',
items: {
type: 'string'
}
},
rules: {
type: 'array',
items: { $ref: 'GroupRule#' }
}
},
examples: [
{
name: 'Editors',
permissions: ['read:pages', 'write:pages']
}
]
},
response: {
200: {
description: 'Group updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const group = await WIKI.models.groups.getGroupById(req.params.groupId)
if (!group) {
return reply.notFound('Group does not exist.')
}
// -> Collect only the fields actually provided
const patch: GroupPatch = {}
if (req.body.name !== undefined) {
patch.name = req.body.name
}
if (req.body.redirectOnLogin !== undefined) {
patch.redirectOnLogin = req.body.redirectOnLogin
}
if (req.body.redirectOnFirstLogin !== undefined) {
patch.redirectOnFirstLogin = req.body.redirectOnFirstLogin
}
if (req.body.redirectOnLogout !== undefined) {
patch.redirectOnLogout = req.body.redirectOnLogout
}
if (req.body.permissions !== undefined) {
patch.permissions = req.body.permissions
}
if (req.body.rules !== undefined) {
patch.rules = req.body.rules
}
if (Object.keys(patch).length < 1) {
throw new CustomError('groupUpdateEmpty', 'No group fields provided to update.')
}
// -> The root administrators group must keep its permissions, or the instance becomes
// unmanageable with no way to grant `manage:system` back.
if (patch.permissions && group.id === WIKI.config.auth.rootAdminGroupId) {
throw new CustomError(
'groupUpdateRootAdminPermissions',
'Cannot modify the permissions of the root administrators group.'
)
}
// -> Rule IDs must be unique within the group, as they address the rule client-side
if (patch.rules) {
const ruleIds = patch.rules.map((r) => r.id)
if (new Set(ruleIds).size !== ruleIds.length) {
throw new CustomError('groupUpdateDuplicateRuleId', 'Group rule IDs must be unique.')
}
}
try {
await WIKI.models.groups.updateGroup(group.id, patch)
return {
ok: true,
message: 'Group updated successfully.'
}
} catch (err: any) {
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
/**
* DELETE GROUP
*/
app.delete<{ Params: { groupId: string } }>(
'/:groupId',
{
config: {
permissions: ['manage:groups']
},
schema: {
summary: 'Delete a group',
description:
'Deletes the group and removes all of its user assignments. System groups cannot be deleted.',
tags: ['Groups'],
params: {
type: 'object',
properties: {
groupId: {
type: 'string',
format: 'uuid'
}
},
required: ['groupId']
},
response: {
204: {
description: 'Group deleted successfully'
}
}
}
},
async (req, reply) => {
const group = await WIKI.models.groups.getGroupById(req.params.groupId)
if (!group) {
return reply.notFound('Group does not exist.')
}
if (group.isSystem) {
return reply.conflict('Cannot delete a system group.')
}
try {
await WIKI.models.groups.deleteGroup(group.id)
return reply.code(204).send()
} catch (err: any) {
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
/**
* LIST GROUP USERS
*/
app.get<{
Params: { groupId: string }
Querystring: { filter?: string; page?: number; limit?: number }
}>(
'/:groupId/users',
{
config: {
permissions: ['read:groups', 'manage:groups']
},
schema: {
summary: 'List the users assigned to a group',
description: 'Returns a page of group members, ordered by name.',
tags: ['Groups'],
params: {
type: 'object',
properties: {
groupId: {
type: 'string',
format: 'uuid'
}
},
required: ['groupId']
},
querystring: {
type: 'object',
properties: {
filter: {
type: 'string',
description: 'Case-insensitive substring matched against the name and email.',
maxLength: 255
},
page: { type: 'integer', minimum: 1, default: 1 },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }
}
},
response: {
200: {
description: 'List of group members',
type: 'object',
properties: {
page: { type: 'integer' },
limit: { type: 'integer' },
total: { type: 'integer' },
users: {
type: 'array',
items: { $ref: 'UserCore#' }
}
}
}
}
}
},
async (req, reply) => {
const group = await WIKI.models.groups.getGroupById(req.params.groupId)
if (!group) {
return reply.notFound('Group does not exist.')
}
const page = req.query.page ?? 1
const limit = req.query.limit ?? 20
const { total, users } = await WIKI.models.groups.getGroupUsers(group.id, {
filter: req.query.filter,
page,
limit
})
return { page, limit, total, users }
}
)
/**
* ASSIGN USER TO GROUP
*/
app.post<{ Params: { groupId: string; userId: string } }>(
'/:groupId/users/:userId',
{
config: {
permissions: ['write:groups', 'manage:groups']
},
schema: {
summary: 'Assign a user to a group',
tags: ['Groups'],
params: {
type: 'object',
properties: {
groupId: {
type: 'string',
format: 'uuid'
},
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['groupId', 'userId']
},
response: {
200: {
description: 'User assigned successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const group = await WIKI.models.groups.getGroupById(req.params.groupId)
if (!group) {
return reply.notFound('Group does not exist.')
}
if (!(await WIKI.models.users.getById(req.params.userId))) {
return reply.notFound('User does not exist.')
}
const assigned = await WIKI.models.groups.assignUserToGroup(group.id, req.params.userId)
if (!assigned) {
return reply.conflict('User is already assigned to this group.')
}
return {
ok: true,
message: 'User assigned to group successfully.'
}
}
)
/**
* UNASSIGN USER FROM GROUP
*/
app.delete<{ Params: { groupId: string; userId: string } }>(
'/:groupId/users/:userId',
{
config: {
permissions: ['write:groups', 'manage:groups']
},
schema: {
summary: 'Unassign a user from a group',
description:
'Removes the user from the group. The last remaining user cannot be removed from the root administrators group.',
tags: ['Groups'],
params: {
type: 'object',
properties: {
groupId: {
type: 'string',
format: 'uuid'
},
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['groupId', 'userId']
},
response: {
204: {
description: 'User unassigned successfully'
}
}
}
},
async (req, reply) => {
const group = await WIKI.models.groups.getGroupById(req.params.groupId)
if (!group) {
return reply.notFound('Group does not exist.')
}
if (!(await WIKI.models.groups.isUserInGroup(group.id, req.params.userId))) {
return reply.notFound('User is not assigned to this group.')
}
// -> Emptying the root administrators group would lock everyone out of system management
if (group.id === WIKI.config.auth.rootAdminGroupId) {
if ((await WIKI.models.groups.countUsersInGroup(group.id)) <= 1) {
return reply.conflict('Cannot remove the last user from the root administrators group.')
}
}
await WIKI.models.groups.unassignUserFromGroup(group.id, req.params.userId)
return reply.code(204).send()
}
)
}
export default routes
-18
View File
@@ -1,18 +0,0 @@
/**
* API Routes
*/
async function routes(app) {
// Register schemas
await import('./schemas/site.js').then((m) => m.registerSchemas(app))
await import('./schemas/user.js').then((m) => m.registerSchemas(app))
// Register routes
app.register(import('./authentication.js'))
app.register(import('./locales.js'), { prefix: '/locales' })
app.register(import('./pages.js'))
app.register(import('./sites.js'), { prefix: '/sites' })
app.register(import('./system.js'), { prefix: '/system' })
app.register(import('./users.js'), { prefix: '/users' })
}
export default routes
+22
View File
@@ -0,0 +1,22 @@
import type { FastifyInstance } from 'fastify'
/**
* API Routes
*/
async function routes(app: FastifyInstance) {
// Register schemas
await import('./schemas/group.ts').then((m) => m.registerSchemas(app))
await import('./schemas/site.ts').then((m) => m.registerSchemas(app))
await import('./schemas/user.ts').then((m) => m.registerSchemas(app))
// Register routes
app.register(import('./authentication.ts'))
app.register(import('./groups.ts'), { prefix: '/groups' })
app.register(import('./locales.ts'), { prefix: '/locales' })
app.register(import('./pages.ts'))
app.register(import('./sites.ts'), { prefix: '/sites' })
app.register(import('./system.ts'), { prefix: '/system' })
app.register(import('./users.ts'), { prefix: '/users' })
}
export default routes
-24
View File
@@ -1,24 +0,0 @@
/**
* Locales API Routes
*/
async function routes (app, options) {
app.get('/', {
schema: {
summary: 'List all locales',
tags: ['Locales']
}
}, async (req, reply) => {
return WIKI.models.locales.getLocales()
})
app.get('/:code/strings', {
schema: {
summary: 'Get locale strings',
tags: ['Locales']
}
}, async (req, reply) => {
return WIKI.models.locales.getStrings(req.params.code)
})
}
export default routes
+34
View File
@@ -0,0 +1,34 @@
import type { FastifyInstance } from 'fastify'
/**
* Locales API Routes
*/
async function routes(app: FastifyInstance) {
app.get(
'/',
{
schema: {
summary: 'List all locales',
tags: ['Locales']
}
},
async () => {
return WIKI.models.locales.getLocales()
}
)
app.get<{ Params: { code: string } }>(
'/:code/strings',
{
schema: {
summary: 'Get locale strings',
tags: ['Locales']
}
},
async (req) => {
return WIKI.models.locales.getStrings(req.params.code)
}
)
}
export default routes
+12 -7
View File
@@ -1,8 +1,10 @@
import type { FastifyInstance } from 'fastify'
/**
* Pages API Routes
*/
async function routes(app, options) {
app.get(
async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/pages',
{
config: {
@@ -22,12 +24,15 @@ async function routes(app, options) {
}
}
},
async (req, reply) => {
async () => {
return []
}
)
app.get(
app.get<{
Params: { siteId: string; pageIdOrHash: string }
Querystring: { withContent?: boolean }
}>(
'/sites/:siteId/pages/:pageIdOrHash',
{
schema: {
@@ -57,12 +62,12 @@ async function routes(app, options) {
}
}
},
async (req, reply) => {
async () => {
return []
}
)
app.post(
app.post<{ Params: { siteId: string }; Body: { path: string } }>(
'/sites/:siteId/pages/userPermissions',
{
schema: {
@@ -95,7 +100,7 @@ async function routes(app, options) {
}
}
},
async (req, reply) => {
async () => {
return []
}
)
+136
View File
@@ -0,0 +1,136 @@
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* GROUP RULE - A single page rule within a group
*/
app.addSchema({
$id: 'GroupRule',
type: 'object',
required: ['id', 'name', 'roles', 'match', 'mode', 'path'],
properties: {
id: {
type: 'string',
description: 'Client-generated identifier, unique within the group.'
},
name: {
type: 'string',
minLength: 1,
maxLength: 255
},
roles: {
type: 'array',
description: 'Permissions granted or denied by this rule.',
items: {
type: 'string'
}
},
match: {
type: 'string',
description: 'How `path` is compared against the page path.',
enum: ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT']
},
mode: {
type: 'string',
description:
'ALLOW grants the roles, DENY revokes them, FORCEALLOW grants them and cannot be overridden by a later DENY.',
enum: ['ALLOW', 'DENY', 'FORCEALLOW']
},
path: {
type: 'string',
maxLength: 255
},
locales: {
type: 'array',
description: 'Locale codes this rule is limited to. Empty means all locales.',
items: {
type: 'string'
}
},
sites: {
type: 'array',
description: 'Site IDs this rule is limited to. Empty means all sites.',
items: {
type: 'string',
format: 'uuid'
}
}
}
})
/**
* GROUP CORE - Essential fields only
*/
app.addSchema({
$id: 'GroupCore',
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
name: {
type: 'string',
minLength: 1,
maxLength: 255
},
isSystem: {
type: 'boolean',
description: 'System groups cannot be deleted.'
},
userCount: {
type: 'number',
description: 'Number of users assigned to this group.'
},
createdAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
},
updatedAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
}
}
})
/**
* GROUP - All fields
*/
app.addSchema({
$id: 'Group',
allOf: [
{
$ref: 'GroupCore#'
},
{
type: 'object',
properties: {
permissions: {
type: 'array',
description: 'Global permissions granted to members of this group.',
items: {
type: 'string'
}
},
rules: {
type: 'array',
items: {
$ref: 'GroupRule#'
}
},
redirectOnLogin: {
type: 'string'
},
redirectOnFirstLogin: {
type: 'string'
},
redirectOnLogout: {
type: 'string'
}
}
}
]
})
}
@@ -1,4 +1,6 @@
export async function registerSchemas(app) {
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* SITE
*/
@@ -1,4 +1,6 @@
export async function registerSchemas(app) {
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* USER CORE - Essential fields only
*/
@@ -42,9 +44,14 @@ export async function registerSchemas(app) {
description: 'RFC 3339 Date Time'
},
lastLoginAt: {
// -> Users who have never logged in have no value here, and a plain `string` would make the
// serializer coerce null to an empty string. `nullable` is used rather than
// `type: ['string', 'null']` because the emitted spec declares OpenAPI 3.0, where a type
// array is not valid.
type: 'string',
nullable: true,
format: 'date-time',
description: 'RFC 3339 Date Time'
description: 'RFC 3339 Date Time, or null if the user has never logged in'
}
}
})
+20 -12
View File
@@ -1,10 +1,11 @@
import { validate as uuidValidate } from 'uuid'
import { CustomError } from '../helpers/common.js'
import { CustomError } from '../helpers/common.ts'
import type { FastifyInstance } from 'fastify'
/**
* Sites API Routes
*/
async function routes(app) {
async function routes(app: FastifyInstance) {
app.get(
'/',
{
@@ -25,7 +26,7 @@ async function routes(app) {
},
async () => {
const sites = await WIKI.models.sites.getAllSites()
return sites.map((s) => ({
return sites.map((s: any) => ({
...s.config,
id: s.id,
hostname: s.hostname,
@@ -39,7 +40,7 @@ async function routes(app) {
}
)
app.get(
app.get<{ Params: { siteIdorHostname: string }; Querystring: { strict?: boolean } }>(
'/:siteIdorHostname',
{
schema: {
@@ -77,18 +78,22 @@ async function routes(app) {
}
},
async (req, reply) => {
let site
let site: any
if (req.params.siteIdorHostname === 'current' && req.hostname) {
site = await WIKI.models.sites.getSiteByHostname({
hostname: req.hostname,
strict: req.querystring?.strict ?? false
// FIXME: see the note below — `req.querystring` is not a Fastify property.
strict: (req as any).querystring?.strict ?? false
})
} else if (uuidValidate(req.params.siteIdorHostname)) {
site = await WIKI.models.sites.getSiteById({ id: req.params.siteIdorHostname })
} else {
site = await WIKI.models.sites.getSiteByHostname({
hostname: req.params.siteIdorHostname,
strict: req.querystring?.strict ?? false
// FIXME: pre-existing bug — Fastify exposes the parsed query string as `req.query`, not
// `req.querystring`, so `strict` is always undefined here and the lookup is never strict.
// Preserved as-is to keep the migration behavior-neutral; the fix is `req.query.strict`.
strict: (req as any).querystring?.strict ?? false
})
}
if (site) {
@@ -112,7 +117,7 @@ async function routes(app) {
/**
* CREATE SITE
*/
app.post(
app.post<{ Body: { hostname: string; title: string } }>(
'/',
{
config: {
@@ -202,7 +207,7 @@ async function routes(app) {
message: 'Site created successfully.',
id: result.id
}
} catch (err) {
} catch (err: any) {
WIKI.logger.warn(err)
return reply.internalServerError()
}
@@ -212,7 +217,10 @@ async function routes(app) {
/**
* UPDATE SITE
*/
app.put(
app.put<{
Params: { siteId: string }
Body: { isEnabled?: boolean; hostname?: string; title?: string }
}>(
'/:siteId',
{
config: {
@@ -256,7 +264,7 @@ async function routes(app) {
/**
* DELETE SITE
*/
app.delete(
app.delete<{ Params: { siteId: string } }>(
'/:siteId',
{
config: {
@@ -291,7 +299,7 @@ async function routes(app) {
} else {
reply.badRequest('Site does not exist.')
}
} catch (err) {
} catch (err: any) {
reply.send(err)
}
}
@@ -1,6 +1,5 @@
import path from 'node:path'
import os from 'node:os'
import { DateTime } from 'luxon'
import { filesize } from 'filesize'
import { isNil } from 'es-toolkit/predicate'
import { gte, sql } from 'drizzle-orm'
@@ -9,12 +8,13 @@ import {
pages as pagesTable,
tags as tagsTable,
users as usersTable
} from '../db/schema.js'
} from '../db/schema.ts'
import type { FastifyInstance } from 'fastify'
/**
* System API Routes
*/
async function routes(app) {
async function routes(app: FastifyInstance) {
/**
* SYSTEM INFO
*/
@@ -217,14 +217,23 @@ async function routes(app) {
const instRaw = await WIKI.db.execute(
sql`SELECT usename, client_addr, application_name, backend_start, state_change FROM pg_stat_activity WHERE datname = ${WIKI.dbManager.dbName} AND application_name LIKE 'Wiki.js%'`
)
const insts = {}
for (const inst of instRaw.rows) {
const insts: Record<string, any> = {}
for (const inst of instRaw.rows as any[]) {
const instId = inst.application_name.substring(10, 20)
const conType = [':MAIN', ':WORKER'].some((ct) => inst.application_name.endsWith(ct))
? 'main'
: 'sub'
inst.backend_start = DateTime.fromSQL(inst.backend_start).toISO()
inst.state_change = DateTime.fromSQL(inst.state_change).toISO()
// -> `db.execute()` with a raw SQL template returns timestamps as postgres-format strings
// (e.g. `2026-07-25 13:17:36.230177+00`) rather than Dates, which is what the previous
// `DateTime.fromSQL()` call was for. Temporal.Instant.from parses that format as-is,
// including the space separator and the hour-only `+00` offset. Rendered with
// millisecond precision to match the timestamps produced elsewhere.
inst.backend_start = Temporal.Instant.from(inst.backend_start).toString({
smallestUnit: 'millisecond'
})
inst.state_change = Temporal.Instant.from(inst.state_change).toString({
smallestUnit: 'millisecond'
})
const curInst = insts[instId] ?? {
activeConnections: 0,
activeListeners: 0,
@@ -287,7 +296,9 @@ async function routes(app) {
maxRetries: 0,
promise: true
})
await renderJob.promise
// NOTE: `addJob` resolves to undefined if enqueueing failed, in which case this throws —
// preserving the existing behavior.
await renderJob!.promise
return {
current: WIKI.version,
latest: WIKI.config.update.version,
+12 -10
View File
@@ -1,8 +1,10 @@
import type { FastifyInstance } from 'fastify'
/**
* Users API Routes
*/
async function routes(app, options) {
app.get(
async function routes(app: FastifyInstance) {
app.get<{ Querystring: { page?: number; limit?: number } }>(
'/',
{
config: {
@@ -35,7 +37,7 @@ async function routes(app, options) {
}
}
},
async (request, reply) => {
async () => {
return { hello: 'world' }
}
)
@@ -64,7 +66,7 @@ async function routes(app, options) {
}
)
app.get(
app.get<{ Params: { userId: string } }>(
'/:userId',
{
config: {
@@ -91,7 +93,7 @@ async function routes(app, options) {
}
}
},
async (request, reply) => {
async () => {
return { hello: 'world' }
}
)
@@ -107,12 +109,12 @@ async function routes(app, options) {
tags: ['Users']
}
},
async (request, reply) => {
async () => {
return { hello: 'world' }
}
)
app.put(
app.put<{ Params: { userId: string } }>(
'/:userId',
{
config: {
@@ -123,12 +125,12 @@ async function routes(app, options) {
tags: ['Users']
}
},
async (request, reply) => {
async () => {
return { hello: 'world' }
}
)
app.delete(
app.delete<{ Params: { userId: string } }>(
'/:userId',
{
config: {
@@ -139,7 +141,7 @@ async function routes(app, options) {
tags: ['Users']
}
},
async (request, reply) => {
async () => {
return { hello: 'world' }
}
)
-61
View File
@@ -1,61 +0,0 @@
import { validate as uuidValidate } from 'uuid'
import { replyWithFile } from '../helpers/common.js'
import path from 'node:path'
/**
* _site Routes
*/
async function routes(app, options) {
const siteAssetsPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'assets')
app.get('/:siteId/:resource', async (req, reply) => {
let site
if (req.params.siteId === 'current' && req.hostname) {
site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
} else if (uuidValidate(req.params.siteId)) {
site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
} else {
site = await WIKI.models.sites.getSiteByHostname({ hostname: req.params.siteId })
}
if (!site) {
return reply.notFound('Site not found')
}
switch (req.params.resource) {
case 'logo': {
if (site.config.assets.logo) {
// TODO: Fetch from db if not in disk cache
return replyWithFile(
reply,
path.join(siteAssetsPath, `logo-${site.id}.${site.config.assets.logoExt}`)
)
} else {
return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/logo-wikijs.svg'))
}
}
case 'favicon': {
if (site.config.assets.favicon) {
// TODO: Fetch from db if not in disk cache
return replyWithFile(
reply,
path.join(siteAssetsPath, `favicon-${site.id}.${site.config.assets.faviconExt}`)
)
} else {
return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/logo-wikijs.svg'))
}
}
case 'loginbg': {
if (site.config.assets.loginBg) {
// TODO: Fetch from db if not in disk cache
return replyWithFile(reply, path.join(siteAssetsPath, `loginbg-${site.id}.jpg`))
} else {
return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/bg/login.jpg'))
}
}
default: {
return reply.badRequest('Invalid Site Resource')
}
}
})
}
export default routes
+65
View File
@@ -0,0 +1,65 @@
import { validate as uuidValidate } from 'uuid'
import { replyWithFile } from '../helpers/common.ts'
import path from 'node:path'
import type { FastifyInstance } from 'fastify'
/**
* _site Routes
*/
async function routes(app: FastifyInstance) {
const siteAssetsPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'assets')
app.get<{ Params: { siteId: string; resource: string } }>(
'/:siteId/:resource',
async (req, reply) => {
let site: any
if (req.params.siteId === 'current' && req.hostname) {
site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
} else if (uuidValidate(req.params.siteId)) {
site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
} else {
site = await WIKI.models.sites.getSiteByHostname({ hostname: req.params.siteId })
}
if (!site) {
return reply.notFound('Site not found')
}
switch (req.params.resource) {
case 'logo': {
if (site.config.assets.logo) {
// TODO: Fetch from db if not in disk cache
return replyWithFile(
reply,
path.join(siteAssetsPath, `logo-${site.id}.${site.config.assets.logoExt}`)
)
} else {
return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/logo-wikijs.svg'))
}
}
case 'favicon': {
if (site.config.assets.favicon) {
// TODO: Fetch from db if not in disk cache
return replyWithFile(
reply,
path.join(siteAssetsPath, `favicon-${site.id}.${site.config.assets.faviconExt}`)
)
} else {
return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/logo-wikijs.svg'))
}
}
case 'loginbg': {
if (site.config.assets.loginBg) {
// TODO: Fetch from db if not in disk cache
return replyWithFile(reply, path.join(siteAssetsPath, `loginbg-${site.id}.jpg`))
} else {
return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/bg/login.jpg'))
}
}
default: {
return reply.badRequest('Invalid Site Resource')
}
}
}
)
}
export default routes
@@ -1,17 +1,23 @@
import { toMerged } from 'es-toolkit/object'
import { isPlainObject } from 'es-toolkit/predicate'
import chalk from 'chalk'
import cfgHelper from '../helpers/config.js'
import cfgHelper from '../helpers/config.ts'
import fs from 'node:fs/promises'
import path from 'node:path'
import yaml from 'js-yaml'
import { v4 as uuid } from 'uuid'
/**
* Config is assembled at runtime from config.yml + base.yml + the `settings` DB table, so its shape
* is only known dynamically. Kept loose on purpose.
*/
type ConfigObject = Record<string, any>
export default {
/**
* Load root config from disk
*/
async init(silent = false) {
async init(silent = false): Promise<void> {
const confPaths = {
config: path.join(WIKI.ROOTPATH, 'config.yml'),
data: path.join(WIKI.SERVERPATH, 'base.yml')
@@ -25,16 +31,18 @@ export default {
process.stdout.write(chalk.blue(`Loading configuration from ${confPaths.config}... `))
}
let appconfig = {}
let appdata = {}
let appconfig: ConfigObject = {}
let appdata: ConfigObject = {}
try {
appconfig = yaml.load(cfgHelper.parseConfigValue(await fs.readFile(confPaths.config, 'utf8')))
appdata = yaml.load(await fs.readFile(confPaths.data, 'utf8'))
appconfig = yaml.load(
cfgHelper.parseConfigValue(await fs.readFile(confPaths.config, 'utf8'))
) as ConfigObject
appdata = yaml.load(await fs.readFile(confPaths.data, 'utf8')) as ConfigObject
if (!silent) {
console.info(chalk.green.bold('OK'))
}
} catch (err) {
} catch (err: any) {
console.error(chalk.red.bold('FAILED'))
console.error(err.message)
@@ -70,8 +78,11 @@ export default {
console.info(chalk.blue('DB_PASS_FILE is defined. Will use secret from file.'))
}
try {
appconfig.db.pass = await fs.readFile(process.env.DB_PASS_FILE, 'utf8').trim()
} catch (err) {
// FIXME: pre-existing bug — `.trim()` is called on the Promise rather than on the resolved
// string, so this always throws and DB_PASS_FILE never works. Preserved as-is to keep the
// TypeScript migration behavior-neutral; the fix is `(await fs.readFile(...)).trim()`.
appconfig.db.pass = await (fs.readFile(process.env.DB_PASS_FILE, 'utf8') as any).trim()
} catch (err: any) {
console.error(
chalk.red.bold(
'>>> Failed to read Docker Secret File using path defined in DB_PASS_FILE env variable!'
@@ -92,7 +103,7 @@ export default {
/**
* Load config from DB
*/
async loadFromDb() {
async loadFromDb(): Promise<boolean> {
WIKI.logger.info('Loading settings from DB...')
const conf = await WIKI.models.settings.getConfig()
if (conf) {
@@ -105,10 +116,10 @@ export default {
/**
* Save config to DB
*
* @param {Array} keys Array of keys to save
* @param keys Array of keys to save
* @returns Promise
*/
async saveToDb(keys, propagate = true) {
async saveToDb(keys: string[], propagate = true): Promise<boolean> {
try {
for (const key of keys) {
let value = WIKI.config[key] ?? null
@@ -120,7 +131,7 @@ export default {
if (propagate) {
WIKI.events.outbound.emit('reloadConfig')
}
} catch (err) {
} catch (err: any) {
WIKI.logger.error(`Failed to save configuration to DB: ${err.message}`)
return false
}
@@ -130,7 +141,7 @@ export default {
/**
* Initialize DB tables with default values
*/
async initDbValues() {
async initDbValues(): Promise<void> {
const ids = {
groupAdminId: uuid(),
groupUserId: WIKI.data.systemIds.usersGroupId,
@@ -151,7 +162,7 @@ export default {
/**
* Subscribe to HA propagation events
*/
subscribeToEvents() {
subscribeToEvents(): void {
WIKI.events.inbound.on('reloadConfig', async () => {
await WIKI.configSvc.loadFromDb()
})
+44 -30
View File
@@ -4,31 +4,45 @@ import fs from 'node:fs/promises'
import { setTimeout } from 'node:timers/promises'
import { drizzle } from 'drizzle-orm/node-postgres'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { Pool } from 'pg'
import { Pool, type PoolClient, type PoolConfig } from 'pg'
import { parse } from 'pg-connection-string'
import semver from 'semver'
import { relations } from '../db/relations.js'
import { createDeferred } from '../helpers/common.js'
import { relations } from '../db/relations.ts'
import { createDeferred } from '../helpers/common.ts'
// import migrationSource from '../db/migrator-source.js'
// const migrateFromLegacy = require('../db/legacy')
/**
* Build the Drizzle instance.
*
* The two branches are spelled out rather than spreading a conditional `{ logger: true }` into a
* single call: a spread in the config literal collapses the inferred relations to `EmptyRelations`,
* which would untype the whole `db.query.*` relational API.
*/
function createDb(client: Pool, logQueries: boolean) {
return logQueries ? drizzle({ client, relations, logger: true }) : drizzle({ client, relations })
}
/** The Drizzle instance, as returned by `init()` and exposed as `WIKI.db`. */
export type WikiDb = ReturnType<typeof createDb>
/**
* ORM DB module
*/
export default {
pool: null,
pubsubClient: null,
config: null,
dbName: null,
VERSION: null,
pool: null as Pool | null,
pubsubClient: null as PoolClient | null,
config: null as PoolConfig | null,
dbName: null as string | null | undefined,
VERSION: null as string | null,
LEGACY: false,
onReady: createDeferred(),
connectAttempts: 0,
/**
* Initialize DB
*/
async init(workerMode = false) {
async init(workerMode = false): Promise<WikiDb> {
WIKI.logger.info('Checking DB configuration...')
// Fetch DB Config
@@ -56,7 +70,7 @@ export default {
WIKI.config.db.ssl === 'true' ||
WIKI.config.db.ssl === 1 ||
WIKI.config.db.ssl === '1'
let sslOptions = null
let sslOptions: any = null
if (dbUseSSL && isPlainObject(this.config) && WIKI.config.db?.sslOptions?.auto === false) {
sslOptions = WIKI.config.db.sslOptions
sslOptions.rejectUnauthorized = sslOptions.rejectUnauthorized !== false
@@ -103,18 +117,14 @@ export default {
options: `-c search_path=${WIKI.config.db.schema}`
})
const db = drizzle({
client: this.pool,
relations,
...(WIKI.config.dev?.logQueries && { logger: true })
})
const db = createDb(this.pool, Boolean(WIKI.config.dev?.logQueries))
// Connect
await this.connect(db)
// Check DB Version
const resVersion = await db.execute('SHOW server_version;')
const dbVersion = semver.coerce(resVersion.rows[0].server_version, { loose: true })
const dbVersion = semver.coerce(resVersion.rows[0].server_version as string, { loose: true })!
this.VERSION = dbVersion.version
if (dbVersion.major < 16) {
WIKI.logger.error(
@@ -140,9 +150,9 @@ export default {
/**
* Subscribe to database LISTEN / NOTIFY for multi-instances events
*/
async subscribeToNotifications() {
async subscribeToNotifications(): Promise<void> {
const connectionAppName = `Wiki.js - ${WIKI.INSTANCE_ID}:EVENTS`
this.pubsubClient = await this.pool.connect()
this.pubsubClient = await this.pool!.connect()
await this.pubsubClient.query(`SET application_name = '${connectionAppName}'`)
// -> Outbound events handling
@@ -153,7 +163,7 @@ export default {
return
}
try {
const decoded = JSON.parse(msg.payload)
const decoded = JSON.parse(msg.payload!)
if ('event' in decoded && decoded.source !== WIKI.INSTANCE_ID) {
WIKI.logger.info(
`Received event ${decoded.event} from instance ${decoded.source}: [ OK ]`
@@ -162,7 +172,11 @@ export default {
}
} catch {}
})
WIKI.events.outbound.onAny(this.notifyViaDB)
// FIXME: pre-existing bug — Emittery's `onAny` calls the listener as `(eventName, eventData)`,
// but `notifyViaDB` destructures a single `{ name, data }` object (the eventemitter2 signature
// it was written against). Both end up undefined, so HA event propagation publishes an empty
// event. Preserved as-is to keep the TypeScript migration behavior-neutral.
WIKI.events.outbound.onAny(this.notifyViaDB as any)
// -> Listen to inbound events
@@ -175,9 +189,9 @@ export default {
/**
* Unsubscribe from database LISTEN / NOTIFY
*/
async unsubscribeFromNotifications() {
async unsubscribeFromNotifications(): Promise<void> {
if (this.pubsubClient) {
WIKI.events.outbound.offAny(this.notifyViaDB)
WIKI.events.outbound.offAny(this.notifyViaDB as any)
WIKI.events.inbound.clearListeners()
this.pubsubClient.release(true)
}
@@ -185,12 +199,12 @@ export default {
/**
* Publish event via database NOTIFY
*
* @param {string} event Event fired
* @param {object} value Payload of the event
* @param event Event fired
* @param value Payload of the event
*/
notifyViaDB({ name, data }) {
notifyViaDB({ name, data }: { name?: string; data?: unknown }): void {
try {
WIKI.dbManager.pubsubClient.query(`SELECT pg_notify($1, $2)`, [
WIKI.dbManager.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [
'wiki',
JSON.stringify({
source: WIKI.INSTANCE_ID,
@@ -198,19 +212,19 @@ export default {
value: data ?? null
})
])
} catch (err) {
} catch (err: any) {
WIKI.logger.warn(err)
}
},
/**
* Attempt initial connection
*/
async connect(db) {
async connect(db: WikiDb): Promise<void> {
try {
WIKI.logger.info('Connecting to database...')
await db.execute('SELECT 1 + 1;')
WIKI.logger.info('Database connection successful [ OK ]')
} catch (err) {
} catch (err: any) {
WIKI.logger.debug(err)
if (this.connectAttempts < 10) {
if (err.code) {
@@ -229,7 +243,7 @@ export default {
/**
* Migrate DB Schemas
*/
async syncSchemas(db) {
async syncSchemas(db: WikiDb) {
WIKI.logger.info('Ensuring DB schema exists...')
await db.execute(`CREATE SCHEMA IF NOT EXISTS ${WIKI.config.db.schema}`)
WIKI.logger.info('Ensuring DB migrations have been applied...')
@@ -1,33 +1,47 @@
import chalk from 'chalk'
import EventEmitter from 'node:events'
const LEVELS = ['error', 'warn', 'info', 'debug']
const LEVELSIGNORED = ['verbose', 'silly']
const LEVELCOLORS = {
export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
export type IgnoredLogLevel = 'verbose' | 'silly'
export type LogFn = (...args: unknown[]) => void
const LEVELS: LogLevel[] = ['error', 'warn', 'info', 'debug']
const LEVELSIGNORED: IgnoredLogLevel[] = ['verbose', 'silly']
const LEVELCOLORS: Record<LogLevel, 'red' | 'yellow' | 'green' | 'cyan'> = {
error: 'red',
warn: 'yellow',
info: 'green',
debug: 'cyan'
}
class Logger extends EventEmitter {}
class Logger extends EventEmitter {
// -> Assigned dynamically in init(). `declare` keeps these type-only so that no class field is
// emitted, leaving the runtime shape of the instance untouched.
declare ws: EventEmitter
declare error: LogFn
declare warn: LogFn
declare info: LogFn
declare debug: LogFn
declare verbose: LogFn
declare silly: LogFn
}
export default {
loggers: {},
init () {
init(): Logger {
const primaryLogger = new Logger()
let ignoreNextLevels = false
primaryLogger.ws = new EventEmitter()
LEVELS.forEach(lvl => {
primaryLogger[lvl] = (...args) => {
LEVELS.forEach((lvl) => {
primaryLogger[lvl] = (...args: unknown[]) => {
primaryLogger.emit(lvl, ...args)
}
if (!ignoreNextLevels) {
primaryLogger.on(lvl, (msg) => {
primaryLogger.on(lvl, (msg: unknown) => {
let formatted = ''
if (WIKI.config.logFormat === 'json') {
formatted = JSON.stringify({
@@ -52,7 +66,7 @@ export default {
}
})
LEVELSIGNORED.forEach(lvl => {
LEVELSIGNORED.forEach((lvl) => {
primaryLogger[lvl] = () => {}
})
@@ -3,9 +3,8 @@ import os from 'node:os'
import fs from 'node:fs/promises'
import path from 'node:path'
import { CronExpressionParser } from 'cron-parser'
import { DateTime } from 'luxon'
import { v4 as uuid } from 'uuid'
import { createDeferred } from '../helpers/common.js'
import { createDeferred, type Deferred } from '../helpers/common.ts'
import { camelCase } from 'es-toolkit/string'
import { remove } from 'es-toolkit/array'
import {
@@ -13,18 +12,47 @@ import {
jobLock as jobLockTable,
jobSchedule as jobScheduleTable,
jobHistory as jobHistoryTable
} from '../db/schema.js'
} from '../db/schema.ts'
import { eq, inArray, sql } from 'drizzle-orm'
import type { PoolClient } from 'pg'
/** An in-process task, loaded from `tasks/simple/`. */
export type SimpleTask = (payload?: any) => Promise<void> | void
/** A pending `addJob({ promise: true })` caller, waiting on the `jobCompleted` event. */
interface CompletionPromise {
id: string
added: Temporal.Instant
resolve: Deferred['resolve']
reject: Deferred['reject']
}
export interface AddJobOptions {
/** The task name to execute. */
task: string
/** An optional data object to pass to the job. */
payload?: any
/** An optional datetime after which the task is allowed to run. */
waitUntil?: Date
/** The number of times this job can be restarted upon failure. Uses server defaults if not provided. */
maxRetries?: number
/** Whether this is a scheduled job. */
isScheduled?: boolean
/** Whether to notify all instances that a new job is available. */
notify?: boolean
/** Whether to return a promise property that resolves when the job completes. */
promise?: boolean
}
export default {
workerPool: null,
pubsubClient: null,
workerPool: null as DynamicThreadPool<any, boolean> | null,
pubsubClient: null as PoolClient | null,
maxWorkers: 1,
activeWorkers: 0,
pollingRef: null,
scheduledRef: null,
tasks: null,
completionPromises: [],
pollingRef: null as NodeJS.Timeout | null,
scheduledRef: null as NodeJS.Timeout | null,
tasks: null as Record<string, SimpleTask> | null,
completionPromises: [] as CompletionPromise[],
async init() {
this.maxWorkers =
WIKI.config.scheduler.workers === 'auto'
@@ -37,36 +65,36 @@ export default {
this.workerPool = new DynamicThreadPool(
1,
this.maxWorkers,
path.join(WIKI.SERVERPATH, 'worker.js'),
path.join(WIKI.SERVERPATH, 'worker.ts'),
{
errorHandler: (err) => WIKI.logger.warn(err),
errorHandler: (err: Error) => WIKI.logger.warn(err),
exitHandler: () => WIKI.logger.debug('A worker has gone offline.'),
onlineHandler: () => WIKI.logger.debug('New worker is online.')
}
)
this.tasks = {}
for (const f of await fs.readdir(path.join(WIKI.SERVERPATH, 'tasks/simple'))) {
const taskName = camelCase(f.replace('.js', ''))
const taskName = camelCase(f.replace(/\.[jt]s$/, ''))
this.tasks[taskName] = (await import(path.join(WIKI.SERVERPATH, 'tasks/simple', f))).task
}
return this
},
async start() {
async start(): Promise<void> {
WIKI.logger.info('Starting Scheduler...')
const connectionAppName = `Wiki.js - ${WIKI.INSTANCE_ID}:SCHEDULER`
this.pubsubClient = await WIKI.dbManager.pool.connect()
await this.pubsubClient.query(`SET application_name = '${connectionAppName}'`)
this.pubsubClient = await WIKI.dbManager.pool!.connect()
await this.pubsubClient!.query(`SET application_name = '${connectionAppName}'`)
// -> Outbound events handling
this.pubsubClient.query('LISTEN scheduler')
this.pubsubClient.on('notification', async (msg) => {
this.pubsubClient!.query('LISTEN scheduler')
this.pubsubClient!.on('notification', async (msg) => {
if (msg.channel !== 'scheduler') {
return
}
try {
const decoded = JSON.parse(msg.payload)
const decoded = JSON.parse(msg.payload!)
switch (decoded?.event) {
case 'newJob': {
if (this.activeWorkers < this.maxWorkers) {
@@ -111,15 +139,6 @@ export default {
},
/**
* Add a job to the scheduler
* @param {Object} opts - Job options
* @param {string} opts.task - The task name to execute.
* @param {Object} [opts.payload={}] - An optional data object to pass to the job.
* @param {Date} [opts.waitUntil] - An optional datetime after which the task is allowed to run.
* @param {Number} [opts.maxRetries] - The number of times this job can be restarted upon failure. Uses server defaults if not provided.
* @param {Boolean} [opts.isScheduled=false] - Whether this is a scheduled job.
* @param {Boolean} [opts.notify=true] - Whether to notify all instances that a new job is available.
* @param {Boolean} [opts.promise=false] - Whether to return a promise property that resolves when the job completes.
* @returns {Promise}
*/
async addJob({
task,
@@ -129,14 +148,14 @@ export default {
isScheduled = false,
notify = true,
promise = false
}) {
}: AddJobOptions): Promise<{ id: string; promise?: Promise<void> } | undefined> {
try {
const jobId = uuid()
const jobDefer = createDeferred()
if (promise) {
this.completionPromises.push({
id: jobId,
added: DateTime.utc(),
added: Temporal.Now.instant(),
resolve: jobDefer.resolve,
reject: jobDefer.reject
})
@@ -144,7 +163,7 @@ export default {
await WIKI.db.insert(jobsTable).values({
id: jobId,
task,
useWorker: !(typeof this.tasks[task] === 'function'),
useWorker: !(typeof this.tasks![task] === 'function'),
payload,
maxRetries: maxRetries ?? WIKI.config.scheduler.maxRetries,
isScheduled,
@@ -152,7 +171,7 @@ export default {
createdBy: WIKI.INSTANCE_ID
})
if (notify) {
this.pubsubClient.query(`SELECT pg_notify($1, $2)`, [
this.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [
'scheduler',
JSON.stringify({
source: WIKI.INSTANCE_ID,
@@ -165,12 +184,12 @@ export default {
id: jobId,
...(promise && { promise: jobDefer.promise })
}
} catch (err) {
} catch (err: any) {
WIKI.logger.warn(`Failed to add job to scheduler: ${err.message}`)
}
},
async processJob() {
const jobIds = []
async processJob(): Promise<void> {
const jobIds: string[] = []
try {
const availableWorkers = this.maxWorkers - this.activeWorkers
if (availableWorkers < 1) {
@@ -178,7 +197,7 @@ export default {
return
}
await WIKI.db.transaction(async (trx) => {
await WIKI.db.transaction(async (trx: any) => {
const jobs = await trx
.delete(jobsTable)
.where(
@@ -215,12 +234,12 @@ export default {
// -> Start working on it
try {
if (job.useWorker) {
await this.workerPool.execute({
await this.workerPool!.execute({
...job,
INSTANCE_ID: `${WIKI.INSTANCE_ID}:WKR`
})
} else {
await this.tasks[job.task](job.payload)
await this.tasks![job.task](job.payload)
}
// -> Update job history (success)
await WIKI.db
@@ -231,7 +250,7 @@ export default {
})
.where(eq(jobHistoryTable.id, job.id))
WIKI.logger.info(`Completed job ${job.id}: ${job.task}`)
this.pubsubClient.query(`SELECT pg_notify($1, $2)`, [
this.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [
'scheduler',
JSON.stringify({
source: WIKI.INSTANCE_ID,
@@ -240,7 +259,7 @@ export default {
id: job.id
})
])
} catch (err) {
} catch (err: any) {
WIKI.logger.warn(`Failed to complete job ${job.id}: ${job.task} [ FAILED ]`)
WIKI.logger.warn(err)
// -> Update job history (fail)
@@ -252,7 +271,7 @@ export default {
lastErrorMessage: err.message
})
.where(eq(jobHistoryTable.id, job.id))
this.pubsubClient.query(`SELECT pg_notify($1, $2)`, [
this.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [
'scheduler',
JSON.stringify({
source: WIKI.INSTANCE_ID,
@@ -268,7 +287,9 @@ export default {
await trx.insert(jobsTable).values({
...job,
retries: job.retries + 1,
waitUntil: DateTime.utc().plus({ seconds: backoffDelay }).toJSDate(),
waitUntil: new Date(
Temporal.Now.instant().add({ seconds: backoffDelay }).epochMilliseconds
),
updatedAt: new Date()
})
WIKI.logger.warn(`Rescheduling new attempt for job ${job.id}: ${job.task}...`)
@@ -277,7 +298,7 @@ export default {
}
}
})
} catch (err) {
} catch (err: any) {
WIKI.logger.warn(err)
if (jobIds && jobIds.length > 0) {
WIKI.db
@@ -290,20 +311,20 @@ export default {
}
}
},
async addScheduled() {
async addScheduled(): Promise<void> {
try {
await WIKI.db.transaction(async (trx) => {
await WIKI.db.transaction(async (trx: any) => {
// -> Acquire lock
const jobLock = await trx
.update(jobLockTable)
.set({
lastCheckedBy: WIKI.INSTANCE_ID,
lastCheckedAt: DateTime.utc().toISO()
lastCheckedAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' })
})
.where(
eq(
jobLockTable.key,
sql`(SELECT "jobLock"."key" FROM "jobLock" WHERE "jobLock"."key" = 'cron' AND "jobLock"."lastCheckedAt" <= ${DateTime.utc().minus({ minutes: 5 }).toISO()} FOR UPDATE SKIP LOCKED LIMIT 1)`
sql`(SELECT "jobLock"."key" FROM "jobLock" WHERE "jobLock"."key" = 'cron' AND "jobLock"."lastCheckedAt" <= ${Temporal.Now.instant().subtract({ minutes: 5 }).toString({ smallestUnit: 'millisecond' })} FOR UPDATE SKIP LOCKED LIMIT 1)`
)
)
@@ -320,29 +341,43 @@ export default {
for (const job of scheduledJobs) {
// -> Get next planned iterations
const plannedIterations = CronExpressionParser.parse(job.cron, {
startDate: DateTime.utc().toISO(),
endDate: DateTime.utc().plus({ days: 1, minutes: 5 }).toISO(),
startDate: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }),
// -> 24 hours rather than `{ days: 1 }`: Temporal.Instant only accepts exact time
// units, and in UTC a calendar day is exactly 24 hours anyway.
endDate: Temporal.Now.instant()
.add({ hours: 24, minutes: 5 })
.toString({ smallestUnit: 'millisecond' }),
tz: 'UTC'
})
// -> Add a maximum of 10 future iterations for a single task
let addedFutureJobs = 0
while (true) {
try {
const next = plannedIterations.next()
// FIXME: pre-existing bug — cron-parser v5's `next()` returns a `CronDate`, not an
// ES iterator result, so `next.value` and `next.done` below are both `undefined`.
// `next.value.getTime()` therefore throws (swallowed by the `catch { break }`)
// whenever `existingJobs` is non-empty, and `next.done` is never true so the loop
// only ever stops at the 10-iteration cap. Cast to `any` to keep the migration
// behavior-neutral; the fix is `next.getTime()` + `plannedIterations.hasNext()`.
const next = plannedIterations.next() as any
// -> Ensure this iteration isn't already scheduled
if (
!existingJobs.some(
(j) => j.task === job.task && j.waitUntil.getTime() === next.value.getTime()
(j: any) =>
j.task === job.task && j.waitUntil.getTime() === next.value.getTime()
)
) {
// FIXME: `useWorker` is not an `addJob` option (it is derived inside `addJob`)
// and `waitUntil` is handed an ISO string rather than a Date. Cast preserves
// the existing call verbatim.
this.addJob({
task: job.task,
useWorker: !(typeof this.tasks[job.task] === 'function'),
useWorker: !(typeof this.tasks![job.task] === 'function'),
payload: job.payload,
isScheduled: true,
waitUntil: next.toISOString(),
notify: false
})
} as any)
addedFutureJobs++
totalAdded++
}
@@ -363,15 +398,15 @@ export default {
}
}
})
} catch (err) {
} catch (err: any) {
WIKI.logger.warn(err)
}
},
async stop() {
async stop(): Promise<void> {
WIKI.logger.info('Stopping Scheduler...')
clearInterval(this.scheduledRef)
clearInterval(this.pollingRef)
await this.workerPool.destroy()
clearInterval(this.scheduledRef!)
clearInterval(this.pollingRef!)
await this.workerPool!.destroy()
WIKI.logger.info('Scheduler: [ STOPPED ]')
}
}
@@ -1,5 +1,5 @@
import { defineRelations } from 'drizzle-orm'
import * as schema from './schema.js'
import * as schema from './schema.ts'
export const relations = defineRelations(schema, (r) => ({
users: {
@@ -1,4 +1,4 @@
import { sql } from 'drizzle-orm'
import { sql, type SQL } from 'drizzle-orm'
import {
bigint,
boolean,
@@ -235,8 +235,10 @@ export const pages = pgTable(
contentType: varchar({ length: 255 }).notNull(),
isBrowsable: boolean().notNull().default(true),
isSearchable: boolean().notNull().default(true),
// -> The generated expression references its own table, so the return type must be annotated
// explicitly to break the circular inference (TS7022/TS7024).
isSearchableComputed: boolean('isSearchableComputed').generatedAlwaysAs(
() => sql`${pages.publishState} != 'draft' AND ${pages.isSearchable}`
(): SQL => sql`${pages.publishState} != 'draft' AND ${pages.isSearchable}`
),
password: varchar({ length: 255 }),
ratingScore: integer().notNull().default(0),
-138
View File
@@ -1,138 +0,0 @@
import { isNil, isPlainObject } from 'es-toolkit/predicate'
import { startCase } from 'es-toolkit/string'
import crypto from 'node:crypto'
import mime from 'mime'
import fs from 'node:fs'
/* eslint-disable promise/param-names */
export function createDeferred() {
let result, resolve, reject
return {
resolve: function (value) {
if (resolve) {
resolve(value)
} else {
result =
result ||
new Promise(function (r) {
r(value)
})
}
},
reject: function (reason) {
if (reject) {
reject(reason)
} else {
result =
result ||
new Promise(function (x, j) {
j(reason)
})
}
},
promise: new Promise(function (r, j) {
if (result) {
r(result)
} else {
resolve = r
reject = j
}
})
}
}
/**
* Decode a tree path
*
* @param {string} str String to decode
* @returns Decoded tree path
*/
export function decodeTreePath(str) {
return str?.replaceAll('.', '/')
}
/**
* Encode a tree path
*
* @param {string} str String to encode
* @returns Encoded tree path
*/
export function encodeTreePath(str) {
return str?.toLowerCase()?.replaceAll('/', '.') || ''
}
/**
* Generate SHA-1 Hash of a string
*
* @param {string} str String to hash
* @returns Hashed string
*/
export function generateHash(str) {
return crypto.createHash('sha1').update(str).digest('hex')
}
/**
* Get default value of type
*
* @param {any} type primitive type name
* @returns Default value
*/
export function getTypeDefaultValue(type) {
switch (type.toLowerCase()) {
case 'string':
return ''
case 'number':
return 0
case 'boolean':
return false
}
}
export function parseModuleProps(props) {
const result = {}
for (const [key, value] of Object.entries(props)) {
let defaultValue = ''
if (isPlainObject(value)) {
defaultValue = !isNil(value.default) ? value.default : getTypeDefaultValue(value.type)
} else {
defaultValue = getTypeDefaultValue(value)
}
result[key] = {
default: defaultValue,
type: (value.type || value).toLowerCase(),
title: value.title || startCase(key),
hint: value.hint || '',
enum: value.enum || false,
enumDisplay: value.enumDisplay || 'select',
multiline: value.multiline || false,
sensitive: value.sensitive || false,
icon: value.icon || 'rename',
order: value.order || 100,
if: value.if ?? []
}
}
return result
}
export function getDictNameFromLocale(locale) {
const loc = locale.length > 2 ? locale.substring(0, 2) : locale
if (loc in WIKI.config.search.dictOverrides) {
return WIKI.config.search.dictOverrides[loc]
} else {
return WIKI.data.tsDictMappings[loc] ?? 'simple'
}
}
export function replyWithFile(reply, filePath) {
const stream = fs.createReadStream(filePath)
reply.header('Content-Type', mime.getType(filePath))
return reply.send(stream)
}
export class CustomError extends Error {
constructor(name, message, statusCode = 400) {
super(message)
this.name = name
this.statusCode = statusCode
}
}
+183
View File
@@ -0,0 +1,183 @@
import { isNil, isPlainObject } from 'es-toolkit/predicate'
import { startCase } from 'es-toolkit/string'
import crypto from 'node:crypto'
import mime from 'mime'
import fs from 'node:fs'
import type { FastifyReply } from 'fastify'
export interface Deferred<T = void> {
resolve: (value: T) => void
reject: (reason?: unknown) => void
promise: Promise<T>
}
/* eslint-disable promise/param-names */
export function createDeferred<T = void>(): Deferred<T> {
let result: Promise<T> | undefined
let resolve: ((value: T | PromiseLike<T>) => void) | undefined
let reject: ((reason?: unknown) => void) | undefined
return {
resolve: function (value: T) {
if (resolve) {
resolve(value)
} else {
result =
result ||
new Promise<T>(function (r) {
r(value)
})
}
},
reject: function (reason?: unknown) {
if (reject) {
reject(reason)
} else {
result =
result ||
new Promise<T>(function (x, j) {
j(reason)
})
}
},
promise: new Promise<T>(function (r, j) {
if (result) {
r(result)
} else {
resolve = r
reject = j
}
})
}
}
/**
* Decode a tree path
*
* @param str String to decode
* @returns Decoded tree path
*/
export function decodeTreePath(str?: string | null): string | undefined {
return str?.replaceAll('.', '/')
}
/**
* Encode a tree path
*
* @param str String to encode
* @returns Encoded tree path
*/
export function encodeTreePath(str?: string | null): string {
return str?.toLowerCase()?.replaceAll('/', '.') || ''
}
/**
* Generate SHA-1 Hash of a string
*
* @param str String to hash
* @returns Hashed string
*/
export function generateHash(str: string): string {
return crypto.createHash('sha1').update(str).digest('hex')
}
/**
* Get default value of type
*
* @param type primitive type name
* @returns Default value
*/
export function getTypeDefaultValue(type: string): string | number | boolean | undefined {
switch (type.toLowerCase()) {
case 'string':
return ''
case 'number':
return 0
case 'boolean':
return false
}
}
/**
* A single prop, as declared in a module `definition.yml`. Either the bare primitive type name
* (e.g. `String`) or an object describing the prop in full.
*/
export type ModulePropDeclaration = ModulePropDefinition | string
export interface ModulePropDefinition {
type: string
default?: unknown
title?: string
hint?: string
enum?: string[] | false
enumDisplay?: string
multiline?: boolean
sensitive?: boolean
icon?: string
order?: number
if?: unknown[]
}
/** A prop after normalization, with every field resolved to a concrete value. */
export interface ModuleProp {
default: unknown
type: string
title: string
hint: string
enum: string[] | false
enumDisplay: string
multiline: boolean
sensitive: boolean
icon: string
order: number
if: unknown[]
}
export function parseModuleProps(
props: Record<string, ModulePropDeclaration>
): Record<string, ModuleProp> {
const result: Record<string, ModuleProp> = {}
for (const [key, value] of Object.entries(props)) {
const def: Partial<ModulePropDefinition> = isPlainObject(value) ? value : {}
const type = def.type || (value as string)
const defaultValue = !isNil(def.default) ? def.default : getTypeDefaultValue(type)
result[key] = {
default: defaultValue,
type: type.toLowerCase(),
title: def.title || startCase(key),
hint: def.hint || '',
enum: def.enum || false,
enumDisplay: def.enumDisplay || 'select',
multiline: def.multiline || false,
sensitive: def.sensitive || false,
icon: def.icon || 'rename',
order: def.order || 100,
if: def.if ?? []
}
}
return result
}
export function getDictNameFromLocale(locale: string): string {
const loc = locale.length > 2 ? locale.substring(0, 2) : locale
if (loc in WIKI.config.search.dictOverrides) {
return WIKI.config.search.dictOverrides[loc]
} else {
return WIKI.data.tsDictMappings[loc] ?? 'simple'
}
}
export function replyWithFile(reply: FastifyReply, filePath: string): FastifyReply {
const stream = fs.createReadStream(filePath)
reply.header('Content-Type', mime.getType(filePath))
return reply.send(stream)
}
export class CustomError extends Error {
statusCode: number
constructor(name: string, message: string, statusCode = 400) {
super(message)
this.name = name
this.statusCode = statusCode
}
}
-21
View File
@@ -1,21 +0,0 @@
const isoDurationReg = /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/
export default {
/**
* Parse configuration value for environment vars
*
* Replaces `$(ENV_VAR_NAME)` with value of `ENV_VAR_NAME` environment variable.
*
* Also supports defaults by if provided as `$(ENV_VAR_NAME:default)`
*
* @param {any} cfg Configuration value
* @returns Parse configuration value
*/
parseConfigValue (cfg) {
return cfg.replaceAll(/\$\(([A-Z0-9_]+)(?::(.+))?\)/g, (fm, m, d) => { return process.env[m] || d })
},
isValidDurationString (val) {
return isoDurationReg.test(val)
}
}
+24
View File
@@ -0,0 +1,24 @@
const isoDurationReg =
/^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/
export default {
/**
* Parse configuration value for environment vars
*
* Replaces `$(ENV_VAR_NAME)` with value of `ENV_VAR_NAME` environment variable.
*
* Also supports defaults by if provided as `$(ENV_VAR_NAME:default)`
*
* @param cfg Configuration value
* @returns Parse configuration value
*/
parseConfigValue(cfg: string): string {
return cfg.replaceAll(/\$\(([A-Z0-9_]+)(?::(.+))?\)/g, (fm: string, m: string, d: string) => {
return process.env[m] || d
})
},
isValidDurationString(val: string): boolean {
return isoDurationReg.test(val)
}
}
+51 -38
View File
@@ -5,7 +5,6 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import { DateTime } from 'luxon'
import semver from 'semver'
import { customAlphabet } from 'nanoid'
import { uniq } from 'es-toolkit/array'
@@ -30,15 +29,15 @@ import pug from 'pug'
import Emittery from 'emittery'
import NodeCache from 'node-cache'
import configSvc from './core/config.js'
import dbManager from './core/db.js'
import logger from './core/logger.js'
import scheduler from './core/scheduler.js'
import configSvc from './core/config.ts'
import dbManager from './core/db.ts'
import logger from './core/logger.ts'
import scheduler from './core/scheduler.ts'
const nanoid = customAlphabet('1234567890abcdef', 10)
if (!semver.satisfies(process.version, '>=24')) {
console.error('ERROR: Node.js 24.x or later required!')
if (!semver.satisfies(process.version, '>=26')) {
console.error('ERROR: Node.js 26.x or later required!')
process.exit(1)
}
@@ -47,6 +46,8 @@ if (existsSync('./package.json')) {
process.exit(1)
}
// The global is assembled progressively: the literal below holds what is known at startup, and
// preBoot()/initHTTPServer() fill in db, models, cache, scheduler, events, app and server.
const WIKI = {
IS_DEBUG: process.env.NODE_ENV === 'development',
ROOTPATH: process.cwd(),
@@ -59,16 +60,16 @@ const WIKI = {
configSvc,
sites: {},
sitesMappings: {},
startedAt: DateTime.utc(),
startedAt: Temporal.Now.instant(),
storage: {
defs: [],
modules: []
}
}
} as unknown as WikiGlobal
global.WIKI = WIKI
if (WIKI.IS_DEBUG) {
process.on('warning', (warning) => {
process.on('warning', (warning: Error) => {
console.log(warning.stack)
})
}
@@ -96,9 +97,9 @@ WIKI.logger.info(`Running node.js ${process.version} [ OK ]`)
// ----------------------------------------
async function preBoot() {
WIKI.dbManager = (await import('./core/db.js')).default
WIKI.dbManager = (await import('./core/db.ts')).default
WIKI.db = await dbManager.init()
WIKI.models = (await import('./models/index.js')).default
WIKI.models = (await import('./models/index.ts')).default
try {
if (await WIKI.configSvc.loadFromDb()) {
@@ -111,7 +112,7 @@ async function preBoot() {
throw new Error('Settings table is empty! Could not initialize [ ERROR ]')
}
}
} catch (err) {
} catch (err: any) {
WIKI.logger.error('Database Initialization Error: ' + err.message)
if (WIKI.IS_DEBUG) {
WIKI.logger.error(err)
@@ -163,10 +164,21 @@ async function initHTTPServer() {
const app = fastify({
ajv: {
plugins: [[ajvFormats, {}]],
onCreate: (ajv) => {
ajv.addFormat('hexcolor', (data) => {
return typeof data === 'string' && data.test(/#[a-fA-F0-9]{6}/)
// -> `ajv-formats` is CJS: the default import resolves to `module.exports`, so the callable
// plugin is reached via `.default` (verified identical at runtime: `f === f.default`).
// The tuple assertion is load-bearing twice over: it stops the element from widening
// (which makes fastify's overload resolution fall through to the HTTP/2 signature), and
// it bridges an upstream variance mismatch — @fastify/ajv-compiler declares plugin
// options as `unknown`, while ajv-formats declares its own narrower options type, and the
// two are contravariantly incompatible. (`ajv` itself is only a nested dependency here, so
// its `Plugin` type is not importable to state this more precisely.)
plugins: [[ajvFormats.default, {}] as any],
onCreate: (ajv: any) => {
ajv.addFormat('hexcolor', (data: unknown) => {
// FIXME: pre-existing bug — this is inverted: strings have no `.test()` method, it
// belongs to RegExp. Any value reaching this format validator throws a TypeError.
// Preserved as-is; the fix is `/#[a-fA-F0-9]{6}/.test(data)`.
return typeof data === 'string' && (data as any).test(/#[a-fA-F0-9]{6}/)
})
}
},
@@ -198,7 +210,7 @@ async function initHTTPServer() {
WIKI.dbManager.unsubscribeFromNotifications()
})
WIKI.server.on(gracefulServer.SHUTDOWN, (err) => {
WIKI.server.on(gracefulServer.SHUTDOWN, (err: Error) => {
WIKI.logger.info(`HTTP Server has exited: [ STOPPED ] (${err.message})`)
if (err.message !== 'SIGINT') {
WIKI.logger.warn(err)
@@ -269,24 +281,24 @@ async function initHTTPServer() {
},
saveUninitialized: false,
store: {
async get(sessionId, clb) {
async get(sessionId: string, clb: (err: any, result?: any) => void) {
try {
clb(null, await WIKI.models.sessions.get(sessionId))
} catch (err) {
} catch (err: any) {
clb(err, null)
}
},
async set(sessionId, sessionData, clb) {
async set(sessionId: string, sessionData: any, clb: (err: any, result?: any) => void) {
try {
clb(null, await WIKI.models.sessions.set(sessionId, sessionData))
} catch (err) {
} catch (err: any) {
clb(err, null)
}
},
async destroy(sessionId, clb) {
async destroy(sessionId: string, clb: (err: any, result?: any) => void) {
try {
clb(null, await WIKI.models.sessions.destroy(sessionId))
} catch (err) {
} catch (err: any) {
clb(err, null)
}
}
@@ -327,14 +339,14 @@ async function initHTTPServer() {
},
security: [{ apiKeyAuth: [] }, { bearerAuth: [] }]
},
transform: ({ schema, url, route }) => {
transform: ({ schema, url, route }: any) => {
// Add permissions to the route schema description
const permissions = route?.config?.permissions ?? []
const transformedSchema = { ...schema }
const currentDescription = transformedSchema.description || ''
if (permissions?.length > 0) {
const nestedPermissions = []
const nestedPermissions: string[] = []
for (const perm of permissions) {
if (Array.isArray(perm)) {
nestedPermissions.push(`\`${perm.join(' + ')}\``)
@@ -356,7 +368,7 @@ async function initHTTPServer() {
})
app.register(fastifySwaggerUi, {
routePrefix: '/_api',
logo: {}
logo: {} as any
})
// ----------------------------------------
@@ -364,15 +376,16 @@ async function initHTTPServer() {
// ----------------------------------------
app.addHook('preHandler', (req, reply, done) => {
if (req.routeOptions.config?.permissions?.length > 0) {
const routePermissions = req.routeOptions.config?.permissions
if (routePermissions && routePermissions.length > 0) {
// Unauthenticated / No Permissions
if (!req.session?.authenticated || !(req.session?.permissions?.length > 0)) {
if (!req.session?.authenticated || !(req.session?.permissions?.length ?? 0)) {
return reply.unauthorized()
}
// Is Root Admin?
if (!req.session.permissions.includes('manage:system')) {
if (!req.session.permissions!.includes('manage:system')) {
// Check for at least 1 permission
const isAllowed = req.routeOptions.config.permissions.some((perms) => {
const isAllowed = routePermissions.some((perms) => {
// Check for all permissions
if (Array.isArray(perms)) {
return perms.every((perm) => req.session.permissions?.some((p) => p === perm))
@@ -394,9 +407,9 @@ async function initHTTPServer() {
// ----------------------------------------
app.addHook('onRequest', (req, reply, done) => {
const [urlPath, urlQuery] = req.raw.url.split('?')
if (urlPath.length > 1 && urlPath.endsWith('/')) {
const newPath = urlPath.slice(0, -1)
const [urlPath, urlQuery] = req.raw.url!.split('?')
if (urlPath!.length > 1 && urlPath!.endsWith('/')) {
const newPath = urlPath!.slice(0, -1)
reply.redirect(urlQuery ? `${newPath}?${urlQuery}` : newPath, 301)
return
}
@@ -458,14 +471,14 @@ async function initHTTPServer() {
// done()
// })
app.register(import('./api/index.js'), { prefix: '/_api' })
app.register(import('./controllers/site.js'), { prefix: '/_site' })
app.register(import('./api/index.ts'), { prefix: '/_api' })
app.register(import('./controllers/site.ts'), { prefix: '/_site' })
// ----------------------------------------
// Error handling
// ----------------------------------------
app.setErrorHandler((error, req, reply) => {
app.setErrorHandler((error: any, req, reply) => {
if (req.url.includes('/_api/')) {
if (error.statusCode) {
reply.code(error.statusCode).type('application/json').send({
@@ -497,7 +510,7 @@ async function initHTTPServer() {
await app.listen({ port: WIKI.config.port, host: WIKI.config.bindIP })
WIKI.logger.info('HTTP Server: [ RUNNING ]')
WIKI.server.setReady()
} catch (err) {
} catch (err: any) {
WIKI.logger.error(err)
process.exit(1)
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Type declaration for the Localazy-generated `metadata.js` in this directory.
*
* `metadata.js` itself is generated output and stays JavaScript (see `localazy.json`), so this
* sibling declaration is what lets the rest of the backend import it with `allowJs` disabled.
* Keep it in sync if the Localazy export shape changes.
*/
export interface LocalazyLanguage {
language: string
region: string
script: string
isRtl: boolean
name: string
localizedName: string
pluralType: (n: number) => string
}
export interface LocalazyMetadata {
projectUrl: string
baseLocale: string
languages: LocalazyLanguage[]
}
declare const localazyMetadata: LocalazyMetadata
export default localazyMetadata
@@ -2,25 +2,26 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import yaml from 'js-yaml'
import { eq } from 'drizzle-orm'
import { parseModuleProps } from '../helpers/common.js'
import { authentication as authenticationTable } from '../db/schema.js'
import { parseModuleProps } from '../helpers/common.ts'
import { authentication as authenticationTable } from '../db/schema.ts'
import type { SystemIds } from './types.ts'
/**
* Authentication model
*/
class Authentication {
async getStrategy(module) {
async getStrategy(module: string) {
return WIKI.db.select().from(authenticationTable).where(eq(authenticationTable.module, module))
}
async getStrategies({ enabledOnly = false } = {}) {
async getStrategies({ enabledOnly = false }: { enabledOnly?: boolean } = {}) {
return WIKI.db
.select()
.from(authenticationTable)
.where(enabledOnly ? eq(authenticationTable.isEnabled, true) : undefined)
}
async refreshStrategiesFromDisk() {
async refreshStrategiesFromDisk(): Promise<void> {
try {
// -> Fetch definitions from disk
const authenticationDirs = await fs.readdir(
@@ -32,7 +33,7 @@ class Authentication {
path.join(WIKI.SERVERPATH, 'modules/authentication', dir, 'definition.yml'),
'utf8'
)
const defParsed = yaml.load(def)
const defParsed = yaml.load(def) as Record<string, any>
if (!defParsed.isAvailable) {
continue
}
@@ -45,23 +46,24 @@ class Authentication {
WIKI.logger.info(
`Loaded ${WIKI.data.authentication.length} authentication module definitions [ OK ]`
)
} catch (err) {
} catch (err: any) {
WIKI.logger.error('Failed to scan or load authentication module definitions [ FAILED ]')
WIKI.logger.error(err)
}
}
async activateStrategies() {
async activateStrategies(): Promise<void> {
WIKI.logger.info('Activating authentication strategies...')
// Unload any active strategies
try {
for (strKey in WIKI.auth.strategies) {
if (typeof WIKI.auth.strategies[strKey].destroy === 'function') {
await WIKI.auth.strategies[strKey].destroy()
for (const strKey in WIKI.auth.strategies) {
const strategy = WIKI.auth.strategies[strKey] as any
if (typeof strategy.destroy === 'function') {
await strategy.destroy()
}
}
} catch (err) {
} catch (err: any) {
WIKI.logger.warn(`Failed to unload active strategies [ FAILED ]`)
WIKI.logger.warn(err)
}
@@ -72,16 +74,17 @@ class Authentication {
for (const stg of enabledStrategies) {
try {
const StrategyModule = (
await import(`../modules/authentication/${stg.module}/authentication.js`)
await import(`../modules/authentication/${stg.module}/authentication.ts`)
).default
WIKI.auth.strategies[stg.id] = new StrategyModule(stg.id, stg.config)
WIKI.auth.strategies[stg.id].module = stg.module
if (typeof WIKI.auth.strategies[stg.id].init === 'function') {
await WIKI.auth.strategies[stg.id].init()
const strategy = new StrategyModule(stg.id, stg.config)
WIKI.auth.strategies[stg.id] = strategy
strategy.module = stg.module
if (typeof strategy.init === 'function') {
await strategy.init()
}
WIKI.logger.info(`Enabled authentication strategy ${stg.displayName} [ OK ]`)
} catch (err) {
} catch (err: any) {
WIKI.logger.error(
`Failed to enable authentication strategy ${stg.displayName} (${stg.id}) [ FAILED ]`
)
@@ -90,7 +93,7 @@ class Authentication {
}
}
async init(ids) {
async init(ids: SystemIds): Promise<void> {
await WIKI.db.insert(authenticationTable).values({
id: ids.authModuleId,
module: 'local',
-59
View File
@@ -1,59 +0,0 @@
import { v4 as uuid } from 'uuid'
import { groups as groupsTable } from '../db/schema.js'
/**
* Groups model
*/
class Groups {
async init(ids) {
WIKI.logger.info('Inserting default groups...')
await WIKI.db.insert(groupsTable).values([
{
id: ids.groupAdminId,
name: 'Administrators',
permissions: ['manage:system'],
rules: [],
isSystem: true
},
{
id: ids.groupUserId,
name: 'Users',
permissions: ['read:pages', 'read:assets', 'read:comments'],
rules: [
{
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments'],
match: 'START',
mode: 'ALLOW',
path: '',
locales: [],
sites: []
}
],
isSystem: true
},
{
id: ids.groupGuestId,
name: 'Guests',
permissions: ['read:pages', 'read:assets', 'read:comments'],
rules: [
{
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments'],
match: 'START',
mode: 'DENY',
path: '',
locales: [],
sites: []
}
],
isSystem: true
}
])
}
}
export const groups = new Groups()
+302
View File
@@ -0,0 +1,302 @@
import { v4 as uuid } from 'uuid'
import { and, count, eq, ilike, or, sql } from 'drizzle-orm'
import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts'
import type { SystemIds } from './types.ts'
/** How a rule's `path` is compared against the page path. */
export type GroupRuleMatch = 'START' | 'END' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT'
/** Whether a matching rule grants, denies, or unconditionally grants its roles. */
export type GroupRuleMode = 'ALLOW' | 'DENY' | 'FORCEALLOW'
/** A single page-rule entry within a group. */
export interface GroupRule {
id: string
name: string
roles: string[]
match: GroupRuleMatch
mode: GroupRuleMode
path: string
locales: string[]
sites: string[]
}
/** A group row, joined with the number of users assigned to it. */
export interface GroupWithUserCount {
id: string
name: string
permissions: string[]
rules: GroupRule[]
redirectOnLogin: string
redirectOnFirstLogin: string
redirectOnLogout: string
isSystem: boolean
userCount: number
createdAt: Date
updatedAt: Date
}
/** The subset of group fields that may be modified. `isSystem` is deliberately absent. */
export interface GroupPatch {
name?: string
redirectOnLogin?: string
redirectOnFirstLogin?: string
redirectOnLogout?: string
permissions?: string[]
rules?: GroupRule[]
}
/**
* Selection shared by getAllGroups() / getGroupById().
*
* `userCount` comes from a left join on `userGroups` aggregated per group, so groups with no members
* count 0 rather than dropping out of the result.
*/
/** A member of a group, mirroring the `UserCore` API schema. */
export interface GroupUser {
id: string
name: string
email: string
hasAvatar: boolean
isSystem: boolean
isActive: boolean
isVerified: boolean
createdAt: Date
updatedAt: Date
lastLoginAt: Date | null
}
export interface GroupUserPage {
total: number
users: GroupUser[]
}
/**
* Escape the LIKE wildcards `%` and `_` (and the escape character itself) so that a user-supplied
* filter is matched literally. Values are still parameterized by the driver — this is about a `%`
* in the filter silently matching everything, not about injection.
*/
function escapeLikePattern(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
}
const groupSelection = {
id: groupsTable.id,
name: groupsTable.name,
permissions: groupsTable.permissions,
rules: groupsTable.rules,
redirectOnLogin: groupsTable.redirectOnLogin,
redirectOnFirstLogin: groupsTable.redirectOnFirstLogin,
redirectOnLogout: groupsTable.redirectOnLogout,
isSystem: groupsTable.isSystem,
createdAt: groupsTable.createdAt,
updatedAt: groupsTable.updatedAt,
userCount: count(userGroups.userId)
}
/**
* Groups model
*/
class Groups {
async init(ids: SystemIds): Promise<void> {
WIKI.logger.info('Inserting default groups...')
await WIKI.db.insert(groupsTable).values([
{
id: ids.groupAdminId,
name: 'Administrators',
permissions: ['manage:system'],
rules: [],
isSystem: true
},
{
id: ids.groupUserId,
name: 'Users',
permissions: ['read:pages', 'read:assets', 'read:comments'],
rules: [
{
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments'],
match: 'START',
mode: 'ALLOW',
path: '',
locales: [],
sites: []
}
],
isSystem: true
},
{
id: ids.groupGuestId,
name: 'Guests',
permissions: ['read:pages', 'read:assets', 'read:comments'],
rules: [
{
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments'],
match: 'START',
mode: 'DENY',
path: '',
locales: [],
sites: []
}
],
isSystem: true
}
])
}
/**
* Fetch all groups, ordered by name
*/
async getAllGroups(): Promise<GroupWithUserCount[]> {
const results = await WIKI.db
.select(groupSelection)
.from(groupsTable)
.leftJoin(userGroups, eq(userGroups.groupId, groupsTable.id))
.groupBy(groupsTable.id)
.orderBy(groupsTable.name)
return results as GroupWithUserCount[]
}
/**
* Fetch a single group by ID
*
* @param id Group ID
* @returns The group, or null if no such group exists
*/
async getGroupById(id: string): Promise<GroupWithUserCount | null> {
const results = await WIKI.db
.select(groupSelection)
.from(groupsTable)
.leftJoin(userGroups, eq(userGroups.groupId, groupsTable.id))
.where(eq(groupsTable.id, id))
.groupBy(groupsTable.id)
.limit(1)
return (results[0] as GroupWithUserCount) ?? null
}
/**
* Update a group
*
* @param id Group ID
* @param patch Fields to change — must not be empty
* @returns Whether a group was updated
*/
async updateGroup(id: string, patch: GroupPatch): Promise<boolean> {
const result = await WIKI.db
.update(groupsTable)
.set({ ...patch, updatedAt: sql`now()` })
.where(eq(groupsTable.id, id))
return (result.rowCount ?? 0) > 0
}
/**
* Delete a group. Assignments in `userGroups` are removed by the FK cascade.
*
* @param id Group ID
* @returns Whether a group was deleted
*/
async deleteGroup(id: string): Promise<boolean> {
const result = await WIKI.db.delete(groupsTable).where(eq(groupsTable.id, id))
return (result.rowCount ?? 0) > 0
}
/**
* Assign a user to a group. Idempotent.
*
* @returns False if the user was already a member
*/
async assignUserToGroup(groupId: string, userId: string): Promise<boolean> {
const result = await WIKI.db
.insert(userGroups)
.values({ userId, groupId })
.onConflictDoNothing()
return (result.rowCount ?? 0) > 0
}
/**
* Remove a user from a group
*
* @returns False if the user was not a member
*/
async unassignUserFromGroup(groupId: string, userId: string): Promise<boolean> {
const result = await WIKI.db
.delete(userGroups)
.where(and(eq(userGroups.groupId, groupId), eq(userGroups.userId, userId)))
return (result.rowCount ?? 0) > 0
}
/**
* Fetch a page of the users assigned to a group, ordered by name.
*
* @param groupId Group ID
* @param filter Optional case-insensitive substring matched against name and email
* @param page 1-based page number
* @param limit Page size
*/
async getGroupUsers(
groupId: string,
{ filter = '', page = 1, limit = 20 }: { filter?: string; page?: number; limit?: number } = {}
): Promise<GroupUserPage> {
const conditions = [eq(userGroups.groupId, groupId)]
if (filter) {
const pattern = `%${escapeLikePattern(filter)}%`
conditions.push(or(ilike(usersTable.name, pattern), ilike(usersTable.email, pattern))!)
}
const where = and(...conditions)
const totals = await WIKI.db
.select({ total: count() })
.from(userGroups)
.innerJoin(usersTable, eq(usersTable.id, userGroups.userId))
.where(where)
const users = await WIKI.db
.select({
id: usersTable.id,
name: usersTable.name,
email: usersTable.email,
hasAvatar: usersTable.hasAvatar,
isSystem: usersTable.isSystem,
isActive: usersTable.isActive,
isVerified: usersTable.isVerified,
createdAt: usersTable.createdAt,
updatedAt: usersTable.updatedAt,
lastLoginAt: usersTable.lastLoginAt
})
.from(userGroups)
.innerJoin(usersTable, eq(usersTable.id, userGroups.userId))
.where(where)
.orderBy(usersTable.name)
.limit(limit)
.offset((page - 1) * limit)
return {
total: totals[0]?.total ?? 0,
users
}
}
/**
* Count the users assigned to a group
*/
async countUsersInGroup(groupId: string): Promise<number> {
return WIKI.db.$count(userGroups, eq(userGroups.groupId, groupId))
}
/**
* Whether a user is currently assigned to a group
*/
async isUserInGroup(groupId: string, userId: string): Promise<boolean> {
const total = await WIKI.db.$count(
userGroups,
and(eq(userGroups.groupId, groupId), eq(userGroups.userId, userId))
)
return total > 0
}
}
export const groups = new Groups()
-19
View File
@@ -1,19 +0,0 @@
import { authentication } from './authentication.js'
import { groups } from './groups.js'
import { jobs } from './jobs.js'
import { locales } from './locales.js'
import { sessions } from './sessions.js'
import { settings } from './settings.js'
import { sites } from './sites.js'
import { users } from './users.js'
export default {
authentication,
groups,
jobs,
locales,
sessions,
settings,
sites,
users
}
+19
View File
@@ -0,0 +1,19 @@
import { authentication } from './authentication.ts'
import { groups } from './groups.ts'
import { jobs } from './jobs.ts'
import { locales } from './locales.ts'
import { sessions } from './sessions.ts'
import { settings } from './settings.ts'
import { sites } from './sites.ts'
import { users } from './users.ts'
export default {
authentication,
groups,
jobs,
locales,
sessions,
settings,
sites,
users
}
@@ -1,9 +1,8 @@
import { DateTime } from 'luxon'
import {
jobSchedule as jobScheduleTable,
jobLock as jobLockTable,
jobHistory as jobHistoryTable
} from '../db/schema.js'
} from '../db/schema.ts'
import { and, eq, lte, not } from 'drizzle-orm'
/**
@@ -13,7 +12,7 @@ class Jobs {
/**
* Initialize jobs table
*/
async init() {
async init(): Promise<void> {
WIKI.logger.info('Inserting scheduled jobs...')
await WIKI.db.insert(jobScheduleTable).values([
@@ -42,22 +41,29 @@ class Jobs {
await WIKI.db.insert(jobLockTable).values({
key: 'cron',
lastCheckedBy: 'init',
lastCheckedAt: DateTime.utc().minus({ hours: 1 }).toISO()
// NOTE: an ISO string, not a Date, is passed deliberately — pg sends it verbatim and
// postgres parses it as UTC, whereas a JS Date would be serialized in the process's local
// timezone. Kept as-is; the cast only silences the column's `Date` type.
lastCheckedAt: Temporal.Now.instant()
.subtract({ hours: 1 })
.toString({ smallestUnit: 'millisecond' }) as any
})
}
/**
* Purge old job history
*/
async cleanHistory() {
await WIKI.db
.delete(jobHistoryTable)
.where(
async cleanHistory(): Promise<void> {
await WIKI.db.delete(jobHistoryTable).where(
and(
not(eq(jobHistoryTable.state, 'active')),
lte(
jobHistoryTable.startedAt,
DateTime.utc().minus({ seconds: WIKI.config.scheduler.historyExpiration }).toJSDate()
new Date(
Temporal.Now.instant().subtract({
seconds: WIKI.config.scheduler.historyExpiration
}).epochMilliseconds
)
)
)
)
@@ -1,14 +1,13 @@
import { stat, readFile } from 'node:fs/promises'
import path from 'node:path'
import { DateTime } from 'luxon'
import { locales as localesTable } from '../db/schema.js'
import { locales as localesTable } from '../db/schema.ts'
import { eq, sql } from 'drizzle-orm'
/**
* Locales model
*/
class Locales {
async refreshFromDisk({ force = false } = {}) {
async refreshFromDisk({ force = false }: { force?: boolean } = {}): Promise<false | void> {
try {
const localesMeta = (await import('../locales/metadata.js')).default
WIKI.logger.info(`Found ${localesMeta.languages.length} locales [ OK ]`)
@@ -34,16 +33,20 @@ class Locales {
const langFilename = langFilenameParts.join('-')
// -> Get DB version
const dbLang = dbLocales.find((l) => l.code === langFilename)
const dbLang = dbLocales.find((l: any) => l.code === langFilename)
// -> Get File version
const flPath = path.join(WIKI.SERVERPATH, `locales/${langFilename}.json`)
try {
const flStat = await stat(flPath)
const flUpdatedAt = DateTime.fromJSDate(flStat.mtime)
const flUpdatedAt = flStat.mtime.toTemporalInstant()
// -> Load strings
if (!dbLang || DateTime.fromJSDate(dbLang.updatedAt) < flUpdatedAt || force) {
if (
!dbLang ||
Temporal.Instant.compare(dbLang.updatedAt.toTemporalInstant(), flUpdatedAt) < 0 ||
force
) {
WIKI.logger.info(`Loading locale ${langFilename} into DB...`)
const flStrings = JSON.parse(await readFile(flPath, 'utf8'))
await WIKI.db
@@ -80,14 +83,14 @@ class Locales {
`${localFilesSkipped} locales were defined in the metadata file but not found on disk. [ SKIPPED ]`
)
}
} catch (err) {
} catch (err: any) {
WIKI.logger.warn('Failed to load locales from disk: [ FAILED ]')
WIKI.logger.warn(err)
return false
}
}
async getLocales({ cache = true } = {}) {
async getLocales({ cache = true }: { cache?: boolean } = {}): Promise<any[]> {
if (!WIKI.cache.has('locales') || !cache) {
const locales = await WIKI.db
.select({
@@ -107,10 +110,10 @@ class Locales {
WIKI.cache.set(`locale:${locale.code}`, locale)
}
}
return WIKI.cache.get('locales')
return WIKI.cache.get('locales') as any[]
}
async getStrings(locale) {
async getStrings(locale: string) {
const results = await WIKI.db
.select({ strings: localesTable.strings })
.from(localesTable)
@@ -119,7 +122,7 @@ class Locales {
return results.length === 1 ? results[0].strings : []
}
async reloadCache() {
async reloadCache(): Promise<void> {
WIKI.logger.info('Reloading locales cache...')
const locales = await WIKI.models.locales.getLocales({ cache: false })
WIKI.logger.info(`Loaded ${locales.length} locales into cache [ OK ]`)
@@ -1,5 +1,5 @@
import { eq, sql } from 'drizzle-orm'
import { sessions as sessionsTable } from '../db/schema.js'
import { sessions as sessionsTable } from '../db/schema.ts'
/**
* Sessions model
@@ -8,20 +8,20 @@ class Sessions {
/**
* Fetch all sessions from a single user
*
* @param {String} userId User ID
* @returns Promise<Array> User Sessions
* @param userId User ID
* @returns User Sessions
*/
async getByUser(userId) {
async getByUser(userId: string) {
return WIKI.db.select().from(sessionsTable).where(eq(sessionsTable.userId, userId))
}
/**
* Fetch a single session by id
*
* @param {String} id Session ID
* @returns Promise<Object> Session data
* @param id Session ID
* @returns Session data
*/
async get(id) {
async get(id: string): Promise<any> {
const res = await WIKI.db.select().from(sessionsTable).where(eq(sessionsTable.id, id))
return res?.[0]?.data ?? null
}
@@ -29,10 +29,10 @@ class Sessions {
/**
* Set / Update a session
*
* @param {String} id Session ID
* @param {Object} data Session Data
* @param id Session ID
* @param data Session Data
*/
async set(id, data) {
async set(id: string, data: any): Promise<void> {
await WIKI.db
.insert(sessionsTable)
.values([
@@ -55,17 +55,15 @@ class Sessions {
/**
* Delete a session
*
* @param {String} id Session ID
* @returns Promise<void>
* @param id Session ID
*/
async destroy(id) {
async destroy(id: string) {
return WIKI.db.delete(sessionsTable).where(eq(sessionsTable.id, id))
}
/**
* Delete all sessions from all users
*
* @returns Promise<void>
*/
async clearAllSessions() {
return WIKI.db.delete(sessionsTable)
@@ -74,10 +72,9 @@ class Sessions {
/**
* Delete all sessions from a single user
*
* @param {String} userId User ID
* @returns Promise<void>
* @param userId User ID
*/
async clearSessionsFromUser(userId) {
async clearSessionsFromUser(userId: string) {
return WIKI.db.delete(sessionsTable).where(eq(sessionsTable.userId, userId))
}
}
@@ -1,6 +1,7 @@
import { settings as settingsTable } from '../db/schema.js'
import { settings as settingsTable } from '../db/schema.ts'
import { pem2jwk } from 'pem-jwk'
import crypto from 'node:crypto'
import type { SystemIds } from './types.ts'
/**
* Settings model
@@ -8,12 +9,12 @@ import crypto from 'node:crypto'
class Settings {
/**
* Fetch settings from DB
* @returns {Promise<Object>} Settings
* @returns Settings, or `false` when the table is empty
*/
async getConfig() {
async getConfig(): Promise<Record<string, any> | false> {
const settings = await WIKI.db.select().from(settingsTable)
if (settings.length > 0) {
return settings.reduce((res, val) => {
return settings.reduce((res: Record<string, any>, val: any) => {
res[val.key] = 'v' in val.value ? val.value.v : val.value
return res
}, {})
@@ -24,10 +25,10 @@ class Settings {
/**
* Apply settings to DB
* @param {string} key Setting key
* @param {Object} value Setting value object
* @param key Setting key
* @param value Setting value object
*/
async updateConfig(key, value) {
async updateConfig(key: string, value: Record<string, any>): Promise<void> {
await WIKI.db
.insert(settingsTable)
.values({ key, value })
@@ -36,9 +37,9 @@ class Settings {
/**
* Initialize settings table
* @param {Object} ids Generated IDs
* @param ids Generated IDs
*/
async init(ids) {
async init(ids: SystemIds): Promise<void> {
WIKI.logger.info('Generating certificates...')
const secret = crypto.randomBytes(32).toString('hex')
const certs = crypto.generateKeyPairSync('rsa', {
@@ -1,20 +1,29 @@
import { toMerged } from 'es-toolkit/object'
import { keyBy } from 'es-toolkit/array'
import { sites as sitesTable } from '../db/schema.js'
import { sites as sitesTable } from '../db/schema.ts'
import { eq } from 'drizzle-orm'
import type { SystemIds } from './types.ts'
/**
* Sites model
*/
class Sites {
async getSiteById({ id, forceReload = false }) {
async getSiteById({ id, forceReload = false }: { id: string; forceReload?: boolean }) {
if (forceReload) {
await WIKI.models.sites.reloadCache()
}
return WIKI.sites[id]
}
async getSiteByHostname({ hostname, forceReload = false, strict = false }) {
async getSiteByHostname({
hostname,
forceReload = false,
strict = false
}: {
hostname: string
forceReload?: boolean
strict?: boolean
}) {
if (forceReload) {
await WIKI.models.sites.reloadCache()
}
@@ -27,7 +36,7 @@ class Sites {
return null
}
async isHostnameUnique(hostname) {
async isHostnameUnique(hostname: string): Promise<boolean> {
return (await WIKI.db.$count(sitesTable, eq(sitesTable.hostname, hostname))) === 0
}
@@ -35,7 +44,7 @@ class Sites {
return WIKI.db.select().from(sitesTable).orderBy(sitesTable.hostname)
}
async reloadCache() {
async reloadCache(): Promise<void> {
WIKI.logger.info('Reloading site configurations...')
const sites = await WIKI.db.select().from(sitesTable).orderBy(sitesTable.id)
WIKI.sites = keyBy(sites, (s) => s.id)
@@ -46,7 +55,7 @@ class Sites {
WIKI.logger.info(`Loaded ${sites.length} site configurations [ OK ]`)
}
async createSite(hostname, config = {}) {
async createSite(hostname: string, config: Record<string, any> = {}) {
const result = await WIKI.db
.insert(sitesTable)
.values({
@@ -184,21 +193,24 @@ class Sites {
return newSite
}
async updateSite(id, patch) {
return WIKI.db.sites.query().findById(id).patch(patch)
async updateSite(id: string, patch: Record<string, any>) {
// FIXME: pre-existing bug — `WIKI.db.sites.query()` is leftover Objection.js API that does not
// exist on a Drizzle instance, so this method always throws. Needs rewriting as a Drizzle
// `update(sitesTable).set(patch).where(eq(sitesTable.id, id))`.
return (WIKI.db as any).sites.query().findById(id).patch(patch)
}
async deleteSite(id) {
async deleteSite(id: string): Promise<boolean> {
// await WIKI.db.storage.query().delete().where('siteId', id)
const deletedResult = await WIKI.db.delete(sitesTable).where(eq(sitesTable.id, id))
return Boolean(deletedResult.rowCount > 0)
return Boolean((deletedResult.rowCount ?? 0) > 0)
}
async countSites() {
return WIKI.db.$count(sitesTable)
}
async init(ids) {
async init(ids: SystemIds): Promise<void> {
WIKI.logger.info('Inserting default site...')
await WIKI.db.insert(sitesTable).values({
+15
View File
@@ -0,0 +1,15 @@
/**
* Generated IDs handed to each model's `init()` during first-run seeding.
*
* Built in `core/config.ts` → `initDbValues()`, mixing freshly generated UUIDs with the fixed
* system IDs declared in `base.yml`.
*/
export interface SystemIds {
groupAdminId: string
groupUserId: string
groupGuestId: string
siteId: string
authModuleId: string
userAdminId: string
userGuestId: string
}
@@ -1,19 +1,41 @@
import bcrypt from 'bcryptjs'
import { userGroups, users as usersTable, userKeys } from '../db/schema.js'
import { userGroups, users as usersTable, userKeys } from '../db/schema.ts'
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { DateTime } from 'luxon'
import { flatten, uniq } from 'es-toolkit/array'
import type { SystemIds } from './types.ts'
export interface LoginOptions {
siteId: string
strategyId: string
username?: string
password?: string
ip?: string
}
export interface AfterLoginResult {
authenticated?: boolean
nextAction: string
continuationToken?: string
tfaQRImage?: string
redirect: string
}
/**
* Users model
*/
class Users {
async getByEmail(email) {
async getByEmail(email: string) {
const res = await WIKI.db.select().from(usersTable).where(eq(usersTable.email, email)).limit(1)
return res?.[0] ?? null
}
async init(ids) {
async getById(id: string) {
const res = await WIKI.db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1)
return res?.[0] ?? null
}
async init(ids: SystemIds): Promise<void> {
WIKI.logger.info('Inserting default users...')
await WIKI.db.insert(usersTable).values([
@@ -78,10 +100,13 @@ class Users {
])
}
async login({ siteId, strategyId, username, password, ip }, req) {
async login(
{ siteId, strategyId, username, password, ip }: LoginOptions,
req: any
): Promise<AfterLoginResult> {
if (strategyId in WIKI.auth.strategies) {
const str = WIKI.auth.strategies[strategyId]
const strInfo = WIKI.data.authentication.find((a) => a.key === str.module)
const str = WIKI.auth.strategies[strategyId] as any
const strInfo = WIKI.data.authentication.find((a: any) => a.key === str.module)
const context = {
ip,
siteId,
@@ -111,13 +136,16 @@ class Users {
}
async afterLoginChecks(
user,
strategyId,
context,
{ skipTFA, skipChangePwd } = { skipTFA: false, skipChangePwd: false },
req
) {
const str = WIKI.auth.strategies[strategyId]
user: any,
strategyId: string,
context: any,
{ skipTFA, skipChangePwd }: { skipTFA?: boolean; skipChangePwd?: boolean } = {
skipTFA: false,
skipChangePwd: false
},
req?: any
): Promise<AfterLoginResult> {
const str = WIKI.auth.strategies[strategyId] as any
if (!str) {
throw new Error('ERR_INVALID_STRATEGY')
}
@@ -139,12 +167,12 @@ class Users {
}
}
})
.then((r) => r?.groups || [])
.then((r: any) => r?.groups || [])
// Get redirect target
let redirect = '/'
if (user.groups && user.groups.length > 0) {
for (const grp of user.groups) {
for (const grp of user.groups as any[]) {
if (grp.redirectOnLogin && grp.redirectOnLogin !== '/') {
redirect = grp.redirectOnLogin
break
@@ -159,7 +187,10 @@ class Users {
if (!skipTFA) {
if (authStr.tfaIsActive && authStr.tfaSecret) {
try {
const tfaToken = await WIKI.db.userKeys.generateToken({
// FIXME: pre-existing bug — `WIKI.db.userKeys` is leftover Objection.js API and does not
// exist on a Drizzle instance, so this throws a TypeError. The intended call is
// `this.generateToken({ ... })`, as used further down in this same file.
const tfaToken = await (WIKI.db as any).userKeys.generateToken({
kind: 'tfa',
userId: user.id,
meta: {
@@ -178,7 +209,10 @@ class Users {
} else if (str.config?.enforceTfa || authStr.tfaRequired) {
try {
const tfaQRImage = await user.generateTFA(strategyId, context.siteId)
const tfaToken = await WIKI.db.userKeys.generateToken({
// FIXME: pre-existing bug — `WIKI.db.userKeys` is leftover Objection.js API and does not
// exist on a Drizzle instance, so this throws a TypeError. The intended call is
// `this.generateToken({ ... })`, as used further down in this same file.
const tfaToken = await (WIKI.db as any).userKeys.generateToken({
kind: 'tfaSetup',
userId: user.id,
meta: {
@@ -230,7 +264,22 @@ class Users {
}
}
async loginChangePassword({ strategyId, siteId, continuationToken, newPassword, ip }, req) {
async loginChangePassword(
{
strategyId,
siteId,
continuationToken,
newPassword,
ip
}: {
strategyId: string
siteId: string
continuationToken: string
newPassword: string
ip?: string
},
req: any
): Promise<AfterLoginResult> {
if (!newPassword || newPassword.length < 8) {
throw new Error('ERR_PASSWORD_TOO_SHORT')
}
@@ -260,7 +309,7 @@ class Users {
}
}
updateSession(user, req) {
updateSession(user: any, req: any): void {
req.session.authenticated = true
req.session.user = {
id: user.id,
@@ -273,23 +322,44 @@ class Users {
appearance: user.prefs?.appearance,
cvd: user.prefs?.cvd
}
req.session.permissions = uniq(flatten(user.groups?.map((g) => g.permissions)))
req.session.permissions = uniq(flatten(user.groups?.map((g: any) => g.permissions)))
}
async generateToken({ userId, kind, meta = {} }) {
async generateToken({
userId,
kind,
meta = {}
}: {
userId: string
kind: string
meta?: Record<string, any>
}): Promise<string> {
WIKI.logger.debug(`Generating ${kind} token for user ${userId}...`)
const token = await nanoid()
await WIKI.db.insert(userKeys).values({
kind,
token,
meta,
validUntil: DateTime.utc().plus({ days: 1 }).toISO(),
// NOTE: ISO string rather than a Date, for the same UTC-vs-local reason as models/jobs.ts.
// 24 hours rather than 1 day: Temporal.Instant takes exact time units only, and in UTC
// a calendar day is exactly 24 hours.
validUntil: Temporal.Now.instant()
.add({ hours: 24 })
.toString({ smallestUnit: 'millisecond' }) as any,
userId
})
return token
}
async validateToken({ kind, token, skipDelete }) {
async validateToken({
kind,
token,
skipDelete
}: {
kind: string
token: string
skipDelete?: boolean
}): Promise<any> {
const res = await WIKI.db.query.userKeys.findFirst({
where: {
kind,
@@ -303,11 +373,18 @@ class Users {
if (skipDelete !== true) {
await WIKI.db.delete(userKeys).where(eq(userKeys.id, res.id))
}
if (DateTime.utc() > DateTime.fromISO(res.validUntil)) {
// -> BEHAVIOR CHANGE (Temporal migration): this previously read
// `DateTime.utc() > DateTime.fromISO(res.validUntil)`. `validUntil` is a `timestamp`
// column, so drizzle hands back a Date, and `fromISO` given a Date produced an *Invalid*
// DateTime whose comparison was always false — tokens never expired. Temporal has no
// Invalid sentinel to reproduce that with, so the check now works as intended.
if (
Temporal.Instant.compare(Temporal.Now.instant(), res.validUntil.toTemporalInstant()) > 0
) {
throw new Error('ERR_EXPIRED_VALIDATION_TOKEN')
}
return {
...res.meta,
...(res.meta as Record<string, any>),
user: res.user
}
} else {
@@ -315,7 +392,7 @@ class Users {
}
}
async destroyToken({ token }) {
async destroyToken({ token }: { token: string }) {
return WIKI.db.delete(userKeys).where(eq(userKeys.token, token))
}
}
@@ -5,15 +5,20 @@ import bcrypt from 'bcryptjs'
// Local Account
// ------------------------------------
export default class LocalAuthentication {
constructor(strategyId, conf) {
strategyId: string
conf: Record<string, any>
/** Set by models/authentication.ts right after construction. */
module?: string
constructor(strategyId: string, conf: Record<string, any>) {
this.strategyId = strategyId
this.conf = conf
}
async authenticate({ username, password }) {
async authenticate({ username, password }: { username: string; password: string }): Promise<any> {
const user = await WIKI.models.users.getByEmail(username.toLowerCase())
if (user) {
const authStrategyData = user.auth[this.strategyId]
const authStrategyData = (user.auth as Record<string, any>)[this.strategyId]
if (!authStrategyData) {
throw new Error('ERR_INVALID_STRATEGY')
} else if ((await bcrypt.compare(password, authStrategyData.password)) !== true) {
+454 -13
View File
@@ -34,7 +34,6 @@
"filesize": "11.0.17",
"fs-extra": "11.3.5",
"js-yaml": "4.2.0",
"luxon": "3.7.2",
"mime": "4.1.0",
"nanoid": "5.1.11",
"node-cache": "5.1.2",
@@ -46,14 +45,22 @@
"uuid": "14.0.0"
},
"devDependencies": {
"@types/fs-extra": "11.0.4",
"@types/js-yaml": "4.0.9",
"@types/node": "26.1.1",
"@types/pem-jwk": "2.0.2",
"@types/pg": "8.20.0",
"@types/pug": "2.0.10",
"@types/semver": "7.7.1",
"drizzle-kit": "1.0.0-beta.15-859cf75",
"nodemon": "3.1.14",
"npm-check-updates": "22.2.3",
"oxfmt": "0.54.0",
"oxlint": "1.69.0"
"oxlint": "1.69.0",
"typescript": "7.0.2"
},
"engines": {
"node": ">=24.0"
"node": ">=26.0"
}
},
"node_modules/@azure-rest/core-client": {
@@ -1990,6 +1997,34 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/fs-extra": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz",
"integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/jsonfile": "*",
"@types/node": "*"
}
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonfile": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz",
"integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/mssql": {
"version": "9.1.9",
"resolved": "https://registry.npmjs.org/@types/mssql/-/mssql-9.1.9.tgz",
@@ -2003,15 +2038,40 @@
}
},
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/pem-jwk": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/pem-jwk/-/pem-jwk-2.0.2.tgz",
"integrity": "sha512-wkdQZtXBObWNxv8Uo1N6SiNU/ZkhvK7UkrBPr0yNvEUlI6/zx2FuQJ95njlw/1jmlKE+cHAFDVB26Z9vNuebfg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/pug": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz",
"integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/readable-stream": {
"version": "4.0.23",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
@@ -2022,6 +2082,353 @@
"@types/node": "*"
}
},
"node_modules/@types/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typespec/ts-http-runtime": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz",
@@ -5133,6 +5540,41 @@
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -5141,11 +5583,10 @@
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT",
"peer": true
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
+41 -32
View File
@@ -1,41 +1,38 @@
{
"name": "wiki-backend",
"version": "3.0.0",
"releaseDate": "2026-01-01T01:01:01.000Z",
"description": "The most powerful and extensible open source Wiki software",
"main": "index.js",
"type": "module",
"private": true,
"dev": true,
"scripts": {
"start": "cd .. && node backend",
"dev": "cd .. && nodemon backend --watch backend --ext mjs,js,json",
"ncu": "ncu -i",
"ncu-u": "ncu -u",
"db-generate": "drizzle-kit generate --dialect=postgresql --schema=./db/schema.js --out=./db/migrations --name=main",
"db-up": "drizzle-kit up --dialect=postgresql --out=./db/migrations"
"description": "The most powerful and extensible open source Wiki software",
"keywords": [
"docs",
"documentation",
"guides",
"knowledge base",
"markdown",
"wiki",
"wikis"
],
"homepage": "https://github.com/requarks/wiki#readme",
"bugs": {
"url": "https://github.com/requarks/wiki/issues"
},
"license": "AGPL-3.0",
"author": "Nicolas Giard",
"repository": {
"type": "git",
"url": "git+https://github.com/requarks/wiki.git"
},
"keywords": [
"wiki",
"wikis",
"docs",
"documentation",
"markdown",
"guides",
"knowledge base"
],
"author": "Nicolas Giard",
"license": "AGPL-3.0",
"bugs": {
"url": "https://github.com/requarks/wiki/issues"
},
"homepage": "https://github.com/requarks/wiki#readme",
"engines": {
"node": ">=24.0"
"type": "module",
"main": "index.ts",
"scripts": {
"start": "cd .. && node backend",
"dev": "cd .. && nodemon backend --watch backend --ext js,ts,json",
"typecheck": "tsc",
"typecheck:watch": "tsc --watch",
"ncu": "ncu -i",
"ncu-u": "ncu -u",
"db-generate": "drizzle-kit generate --dialect=postgresql --schema=./db/schema.ts --out=./db/migrations --name=main",
"db-up": "drizzle-kit up --dialect=postgresql --out=./db/migrations"
},
"dependencies": {
"@fastify/compress": "9.0.0",
@@ -63,7 +60,6 @@
"filesize": "11.0.17",
"fs-extra": "11.3.5",
"js-yaml": "4.2.0",
"luxon": "3.7.2",
"mime": "4.1.0",
"nanoid": "5.1.11",
"node-cache": "5.1.2",
@@ -75,15 +71,28 @@
"uuid": "14.0.0"
},
"devDependencies": {
"@types/fs-extra": "11.0.4",
"@types/js-yaml": "4.0.9",
"@types/node": "26.1.1",
"@types/pem-jwk": "2.0.2",
"@types/pg": "8.20.0",
"@types/pug": "2.0.10",
"@types/semver": "7.7.1",
"drizzle-kit": "1.0.0-beta.15-859cf75",
"nodemon": "3.1.14",
"npm-check-updates": "22.2.3",
"oxfmt": "0.54.0",
"oxlint": "1.69.0"
"oxlint": "1.69.0",
"typescript": "7.0.2"
},
"engines": {
"node": ">=26.0"
},
"collective": {
"type": "opencollective",
"url": "https://opencollective.com/wikijs",
"logo": "https://opencollective.com/opencollective/logo.txt"
}
},
"dev": true,
"releaseDate": "2026-01-01T01:01:01.000Z"
}
@@ -1,10 +1,10 @@
export async function task() {
export async function task(): Promise<void> {
WIKI.logger.info('Checking for latest version...')
try {
const resp = await fetch('https://api.github.com/repos/requarks/wiki/releases/latest').then(
(r) => r.json()
)
const resp: { tag_name: string; published_at: string } = await fetch(
'https://api.github.com/repos/requarks/wiki/releases/latest'
).then((r) => r.json() as Promise<{ tag_name: string; published_at: string }>)
const strictVersion =
resp.tag_name.indexOf('v') === 0 ? resp.tag_name.substring(1) : resp.tag_name
WIKI.logger.info(`Latest version is ${resp.tag_name}.`)
@@ -16,7 +16,7 @@ export async function task() {
await WIKI.configSvc.saveToDb(['update'])
WIKI.logger.info('Checked for latest version: [ COMPLETED ]')
} catch (err) {
} catch (err: any) {
WIKI.logger.error('Checking for latest version: [ FAILED ]')
WIKI.logger.error(err.message)
throw err
@@ -1,11 +1,11 @@
export async function task() {
export async function task(): Promise<void> {
WIKI.logger.info('Cleaning scheduler job history...')
try {
await WIKI.models.jobs.cleanHistory()
WIKI.logger.info('Cleaned scheduler job history: [ COMPLETED ]')
} catch (err) {
} catch (err: any) {
WIKI.logger.error('Cleaning scheduler job history: [ FAILED ]')
WIKI.logger.error(err.message)
throw err
@@ -1,6 +1,6 @@
import { setTimeout } from 'node:timers/promises'
export async function task() {
export async function task(): Promise<void> {
if (WIKI.config.update?.locales === false) {
return
}
@@ -8,9 +8,19 @@ export async function task() {
WIKI.logger.info('Fetching latest localization data...')
try {
interface LocaleMetadata {
languages: {
language: string
region?: string
script?: string
name: string
localizedName: string
isRtl: boolean
}[]
}
const metadata = await fetch(
'https://github.com/requarks/wiki-locales/raw/main/locales/metadata.json'
).then((r) => r.json())
).then((r) => r.json() as Promise<LocaleMetadata>)
for (const lang of metadata.languages) {
// -> Build filename
const langFilenameParts = [lang.language]
@@ -48,7 +58,7 @@ export async function task() {
}
WIKI.logger.info('Fetched latest localization data: [ COMPLETED ]')
} catch (err) {
} catch (err: any) {
WIKI.logger.error('Fetching latest localization data: [ FAILED ]')
WIKI.logger.error(err.message)
throw err
@@ -1,25 +1,26 @@
import path from 'node:path'
import fse from 'fs-extra'
import { DateTime } from 'luxon'
export async function task() {
export async function task(): Promise<void> {
WIKI.logger.info('Purging orphaned upload files...')
try {
const uplTempPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'uploads')
await fse.ensureDir(uplTempPath)
const ls = await fse.readdir(uplTempPath)
const fifteenAgo = DateTime.now().minus({ minutes: 15 })
const fifteenAgo = Temporal.Now.instant().subtract({ minutes: 15 })
for (const f of ls) {
const stat = await fse.stat(path.join(uplTempPath, f))
if (stat.isFile() && stat.ctime < fifteenAgo) {
// -> Compared as epoch millis. Temporal deliberately has no `valueOf`, so relational
// operators on its types throw — comparisons must be explicit.
if (stat.isFile() && stat.ctime.getTime() < fifteenAgo.epochMilliseconds) {
await fse.unlink(path.join(uplTempPath, f))
}
}
WIKI.logger.info('Purging orphaned upload files: [ COMPLETED ]')
} catch (err) {
} catch (err: any) {
WIKI.logger.error('Purging orphaned upload files: [ FAILED ]')
WIKI.logger.error(err.message)
throw err
+37
View File
@@ -0,0 +1,37 @@
{
"compilerOptions": {
// -> Node 26 runs TypeScript directly by stripping types at load time, so there is
// no build step. `tsc` is only ever used as a type checker (see `npm run typecheck`).
"noEmit": true,
// -> Module resolution matching Node's own ESM resolver
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "esnext",
"lib": ["esnext"],
"types": ["node"],
"resolveJsonModule": true,
// -> Required for type stripping:
// Node needs the real on-disk specifier, so relative imports must say `.ts`.
"allowImportingTsExtensions": true,
// Node can only erase types, never transform them. Bans enums, namespaces,
// parameter properties and anything else that emits runtime code.
"erasableSyntaxOnly": true,
// Forces `import type` for type-only imports, so nothing survives erasure.
"verbatimModuleSyntax": true,
"isolatedModules": true,
// -> The backend is fully TypeScript; the only remaining .js is generated/vendored content
// (locales/metadata.js), which is excluded below.
"allowJs": false,
// -> Correctness
"strict": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "db/migrations", "locales"]
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Module augmentations for Fastify.
*
* `@fastify/session` exposes `interface Session` inside the `fastify` module as the extension point
* for application session data; everything Wiki.js stores on the session is declared here.
*/
import 'fastify'
import '@fastify/session'
declare module 'fastify' {
interface Session {
/** Set by `models/users.ts` → `updateSession()` once a login completes. */
authenticated?: boolean
user?: {
id: string
email: string
name: string
hasAvatar?: boolean
timezone?: string
dateFormat?: string
timeFormat?: string
appearance?: string
cvd?: string
}
/** Flattened, de-duplicated permissions of every group the user belongs to. */
permissions?: string[]
}
interface FastifyContextConfig {
/**
* Permissions required to reach the route, enforced by the `preHandler` hook in `index.ts`.
*
* The outer array is OR-ed; a nested array is AND-ed. `manage:system` bypasses the check.
*/
permissions?: (string | string[])[]
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Ambient declarations for the `WIKI` global singleton.
*
* `WIKI` is assembled in `backend/index.ts` (and a minimal subset in `backend/worker.ts`) and is
* reachable from every module without importing it. Members that come from typed dependencies are
* typed properly here; the ones backed by our own not-yet-converted modules are left loose and
* should be replaced with `typeof import('...')` as each module moves to TypeScript.
*/
import type { FastifyInstance } from 'fastify'
import type gracefulServer from '@gquittet/graceful-server'
import type Emittery from 'emittery'
import type NodeCache from 'node-cache'
declare global {
interface WikiGlobal {
IS_DEBUG: boolean
ROOTPATH: string
SERVERPATH: string
INSTANCE_ID: string
startedAt: Temporal.Instant
version: string
releaseDate: string
devMode: boolean
app: FastifyInstance
server: ReturnType<typeof gracefulServer>
cache: NodeCache
/**
* HA propagation buses. Event names are dynamic (they travel over postgres NOTIFY), so the
* event map is left open — `Record<string, any>` is also what makes dataless `emit(name)`
* calls legal, since Emittery's default `unknown` payload forbids them.
*/
events: {
inbound: Emittery<Record<string, any>>
outbound: Emittery<Record<string, any>>
}
auth: {
groups: Record<string, unknown>
strategies: Record<string, unknown>
}
storage: {
defs: unknown[]
modules: unknown[]
}
/**
* Merged config.yml + base.yml defaults + the `settings` DB table. Assembled at runtime from
* YAML and JSONB, so it stays intentionally untyped.
*/
config: any
/** Contents of `base.yml` — set by configSvc.init(), not by index.ts */
data: any
configSvc: typeof import('../core/config.ts').default
db: import('../core/db.ts').WikiDb
dbManager: typeof import('../core/db.ts').default
logger: ReturnType<typeof import('../core/logger.ts').default.init>
scheduler: typeof import('../core/scheduler.ts').default
models: typeof import('../models/index.ts').default
// TODO: infer from the `sites` table once db/schema.ts is converted
sites: Record<string, any>
sitesMappings: Record<string, string>
/**
* FIXME: never assigned anywhere in the codebase. The three
* `throw new WIKI.Error.AuthGenericError()` sites in models/users.ts therefore raise a
* TypeError rather than the intended error. Declared only so the migration can typecheck.
*/
Error: any
/** Only present in worker threads (see worker.ts) */
ensureDb?: () => Promise<boolean | void>
}
var WIKI: WikiGlobal
}
+7 -7
View File
@@ -1,9 +1,9 @@
import { ThreadWorker } from 'poolifier'
import { kebabCase } from 'es-toolkit/string'
import path from 'node:path'
import configSvc from './core/config.js'
import logger from './core/logger.js'
import dbManager from './core/db.js'
import configSvc from './core/config.ts'
import logger from './core/logger.ts'
import dbManager from './core/db.ts'
// ----------------------------------------
// Init Minimal Core
@@ -24,7 +24,7 @@ const WIKI = {
try {
await WIKI.configSvc.loadFromDb()
} catch (err) {
} catch (err: any) {
WIKI.logger.error('Database Initialization Error: ' + err.message)
if (WIKI.IS_DEBUG) {
WIKI.logger.error(err)
@@ -32,7 +32,7 @@ const WIKI = {
process.exit(1)
}
}
}
} as unknown as WikiGlobal
global.WIKI = WIKI
await WIKI.configSvc.init(true)
@@ -47,9 +47,9 @@ WIKI.logger = logger.init()
// Execute Task
// ----------------------------------------
export default new ThreadWorker(async (job) => {
export default new ThreadWorker(async (job: any) => {
WIKI.INSTANCE_ID = job.INSTANCE_ID
const task = (await import(`./tasks/workers/${kebabCase(job.task)}.js`)).task
const task = (await import(`./tasks/workers/${kebabCase(job.task)}.ts`)).task
await task(job)
return true
})
+1 -3
View File
@@ -1,4 +1,4 @@
FROM node:24
FROM node:26
LABEL maintainer="requarks.io"
RUN apt-get update && apt-get install -qy --no-install-recommends \
@@ -14,8 +14,6 @@ RUN mkdir -p /wiki && \
mkdir -p /logs && \
mkdir -p /wiki/data/content && \
chown -R node:node /wiki /logs
RUN corepack enable && \
corepack prepare pnpm@latest --activate
WORKDIR /wiki
+1 -2
View File
@@ -95,7 +95,6 @@
"vite-plugin-vue-devtools": "8.1.2"
},
"engines": {
"node": ">= 18.0",
"npm": ">= 6.13.4"
"node": ">= 26.0"
}
}