mirror of
https://github.com/requarks/wiki.git
synced 2026-08-26 13:17:10 -05:00
refactor: migrate backend to Typescript + other modernization
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ cd ../frontend
|
||||
npm install
|
||||
cd ../blocks
|
||||
npm install
|
||||
npm build
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
echo "Ready!"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
|
||||
@@ -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
@@ -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/**"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
}
|
||||
)
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
@@ -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' }
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
// -> 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),
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+26
@@ -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',
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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({
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -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) {
|
||||
Generated
+454
-13
@@ -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
@@ -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
|
||||
+2
-2
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
Vendored
+38
@@ -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[])[]
|
||||
}
|
||||
}
|
||||
Vendored
+79
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
"vite-plugin-vue-devtools": "8.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0",
|
||||
"npm": ">= 6.13.4"
|
||||
"node": ">= 26.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user