mirror of https://github.com/requarks/wiki
parent
6f492f0028
commit
ee7a15fbd6
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
@ -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
|
||||||
@ -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
|
* SITE
|
||||||
*/
|
*/
|
||||||
@ -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,5 +1,5 @@
|
|||||||
import { defineRelations } from 'drizzle-orm'
|
import { defineRelations } from 'drizzle-orm'
|
||||||
import * as schema from './schema.js'
|
import * as schema from './schema.ts'
|
||||||
|
|
||||||
export const relations = defineRelations(schema, (r) => ({
|
export const relations = defineRelations(schema, (r) => ({
|
||||||
users: {
|
users: {
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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,11 +1,11 @@
|
|||||||
export async function task() {
|
export async function task(): Promise<void> {
|
||||||
WIKI.logger.info('Cleaning scheduler job history...')
|
WIKI.logger.info('Cleaning scheduler job history...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await WIKI.models.jobs.cleanHistory()
|
await WIKI.models.jobs.cleanHistory()
|
||||||
|
|
||||||
WIKI.logger.info('Cleaned scheduler job history: [ COMPLETED ]')
|
WIKI.logger.info('Cleaned scheduler job history: [ COMPLETED ]')
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
WIKI.logger.error('Cleaning scheduler job history: [ FAILED ]')
|
WIKI.logger.error('Cleaning scheduler job history: [ FAILED ]')
|
||||||
WIKI.logger.error(err.message)
|
WIKI.logger.error(err.message)
|
||||||
throw err
|
throw err
|
||||||
@ -1,25 +1,26 @@
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import fse from 'fs-extra'
|
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...')
|
WIKI.logger.info('Purging orphaned upload files...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uplTempPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'uploads')
|
const uplTempPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'uploads')
|
||||||
await fse.ensureDir(uplTempPath)
|
await fse.ensureDir(uplTempPath)
|
||||||
const ls = await fse.readdir(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) {
|
for (const f of ls) {
|
||||||
const stat = await fse.stat(path.join(uplTempPath, f))
|
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))
|
await fse.unlink(path.join(uplTempPath, f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
WIKI.logger.info('Purging orphaned upload files: [ COMPLETED ]')
|
WIKI.logger.info('Purging orphaned upload files: [ COMPLETED ]')
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
WIKI.logger.error('Purging orphaned upload files: [ FAILED ]')
|
WIKI.logger.error('Purging orphaned upload files: [ FAILED ]')
|
||||||
WIKI.logger.error(err.message)
|
WIKI.logger.error(err.message)
|
||||||
throw err
|
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"]
|
||||||
|
}
|
||||||
@ -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[])[]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in new issue