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
|
||||
*/
|
||||
@ -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 * as schema from './schema.js'
|
||||
import * as schema from './schema.ts'
|
||||
|
||||
export const relations = defineRelations(schema, (r) => ({
|
||||
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...')
|
||||
|
||||
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,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"]
|
||||
}
|
||||
@ -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