refactor: use iconify for icon sets + wire storage view

scarlett
NGPixel 2 months ago
parent d1c41b4111
commit 1786c3a569
No known key found for this signature in database

@ -43,7 +43,9 @@ scheduler → event emitters), `initHTTPServer()` (Fastify plugins, auth, routes
- `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`.
background) under `/_site`; `icons.ts` serves icons under `/_icons`, implementing the part of the
Iconify API protocol the frontend speaks (`/_icons/<prefix>.json?icons=a,b` and
`/_icons/<prefix>/<name>.svg`). Public and cached hard — see [Icons](#icons).
- `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).
@ -53,7 +55,9 @@ scheduler → event emitters), `initHTTPServer()` (Fastify plugins, auth, routes
`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/`.
`modules/authentication/local/`. `modules/storage/*` is definition-only so far: the admin area
stores a configuration per site and module, but no `storage.ts` exists yet and nothing reads or
writes content through a target — pages and assets go straight to the database.
- `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
@ -73,8 +77,9 @@ Quasar app on plain Vite (not Quasar CLI). `src/main.js` wires it up manually: r
- `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`, `temporal.js` (conditionally polyfills `Temporal`,
awaited before anything else in `main.js`).
mitt), `externals.js`, `i18n.js`, `iconify.js` (points Iconify at this instance's `/_icons`),
`monaco.js`, `temporal.js` (conditionally polyfills `Temporal`, awaited before anything else in
`main.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`.
@ -90,8 +95,8 @@ Quasar app on plain Vite (not Quasar CLI). `src/main.js` wires it up manually: r
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.
Dev server runs on **3001** and proxies `/_api`, `/_blocks`, `/_icons`, `/_site`, `/_thumb`, `/_user`
to the backend on **3000**, so the backend must be running too.
### `blocks/`
@ -165,12 +170,14 @@ literal and assert it to `WikiGlobal`, since each populates the object progressi
`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
**Four 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')`
- `models/storage.ts``import('../modules/storage/${key}/storage.ts')`, plus the `storage.ts`
presence check in `hasImplementation()` that gates it
`scheduler.ts` reads `tasks/simple/` filenames with `/\.[jt]s$/`, so task files are extension-agnostic.
@ -282,9 +289,31 @@ These apply to **every workspace**, `frontend/` included — not just the backen
[Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older
files are on their way out.
### Icons
Icons come from **Iconify** and are referenced the way Iconify references them — `<prefix>:<name>`,
e.g. `mdi:account-edit`. That string is all that content, navigation items and page relations ever
store; no SVG is ever written into content.
- **Admin** (`AdminIcons.vue` → `/_api/icons`) manages which sets exist: adding a set stores its
metadata only, and enabling/disabling one controls whether its icons can be searched and filled in.
- **`models/icons.ts`** resolves a reference through four tiers — memory, disk
(`<dataPath>/cache/icons/<prefix>/<name>.json`), the `icons` db table, then the Iconify API. **Only
the db is permanent**; the disk cache is derived and starts empty on a fresh instance, so never treat
it as storage. The upstream API is consulted only for an icon nobody has used yet, is capped per
minute (public routes can trigger a fill), and is skipped entirely when `offline` is set.
- **Serving** is `controllers/icons.ts` under `/_icons`, cached for a year and immutable. Rendering a
page never resolves an icon server-side.
- **Frontend**: render every user-supplied icon reference with `<wiki-icon :name>`, which draws an
Iconify reference through the `iconify-icon` element and hands anything else (`las la-cog`,
`mdi-check`, `img:…`) to `q-icon`. Passing such a reference to a Quasar `icon` prop does *not* work —
use the component's icon slot instead.
- Picking an icon calls `POST /_api/icons/materialize`, which is what guarantees the wiki can serve it
afterwards without the Iconify API.
### GraphQL is being removed
An earlier iteration of 3.x used GraphQL/Apollo. 59 files under `frontend/src/` still reference
An earlier iteration of 3.x used GraphQL/Apollo. 29 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.

@ -0,0 +1,579 @@
import type { FastifyInstance } from 'fastify'
/**
* Permissions for looking icons up and storing them.
*
* Anyone who can put an icon somewhere a page, a navigation item, a page relation needs to be able
* to search for one and have it stored, which is what makes it servable from this instance afterwards.
*/
const PICKER_PERMISSIONS = ['write:pages', 'manage:pages', 'manage:sites', 'manage:system']
/**
* Icons API Routes
*
* Administration of the icon sets, plus the search and materialize calls the icon picker makes. The
* icons themselves are served outside `/_api`, under `/_icons` see `controllers/icons.ts`.
*/
async function routes(app: FastifyInstance) {
/**
* LIST ADDED ICON SETS
*/
app.get(
'/sets',
{
config: {
permissions: PICKER_PERMISSIONS
},
schema: {
summary: 'List the icon sets added to this wiki',
description:
'Alphabetical. `iconCount` is how many icons of the set are stored in the database, which is what this instance can serve on its own — the disk cache is derived from those rows and may be empty.',
tags: ['Icons'],
response: {
200: {
description: 'List of icon sets',
type: 'array',
items: { $ref: 'IconSet#' }
}
}
}
},
async () => {
return WIKI.models.icons.getSets()
}
)
/**
* ADD ICON SET
*/
app.post<{ Body: { prefix: string } }>(
'/sets',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Add an icon set',
description:
'The set must exist upstream, and its name and metadata are taken from there — so this call needs outbound access to the Iconify API. Nothing is downloaded beyond the metadata: icons are stored the first time something references them.',
tags: ['Icons'],
body: {
type: 'object',
required: ['prefix'],
properties: {
prefix: {
type: 'string',
maxLength: 64,
description: 'Iconify prefix of the set, e.g. `tabler`.'
}
}
},
response: {
200: {
description: 'Icon set added successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
set: { $ref: 'IconSet#' }
}
}
}
}
},
async (req, reply) => {
try {
const set = await WIKI.models.icons.addSet(req.body.prefix.toLowerCase())
return {
ok: true,
message: `The ${set.name} icon set has been added.`,
set
}
} catch (err: any) {
WIKI.logger.warn(err.message)
return reply.badRequest(err.message)
}
}
)
/**
* ENABLE / DISABLE ICON SET
*/
app.put<{ Params: { prefix: string }; Body: { isEnabled: boolean } }>(
'/sets/:prefix',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Enable or disable an icon set',
description:
'A disabled set disappears from the picker and stops taking on new icons. Icons already stored for it keep being served, since content referencing them is already published.',
tags: ['Icons'],
params: {
type: 'object',
properties: {
prefix: {
type: 'string',
maxLength: 64
}
},
required: ['prefix']
},
body: {
type: 'object',
required: ['isEnabled'],
properties: {
isEnabled: {
type: 'boolean'
}
}
},
response: {
200: {
description: 'Icon set updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const prefix = req.params.prefix.toLowerCase()
if (!(await WIKI.models.icons.getSet(prefix))) {
return reply.notFound('Icon set has not been added.')
}
await WIKI.models.icons.setSetState(prefix, req.body.isEnabled)
return {
ok: true,
message: `The ${prefix} icon set has been ${req.body.isEnabled ? 'enabled' : 'disabled'}.`
}
}
)
/**
* DELETE ICON SET
*/
app.delete<{ Params: { prefix: string } }>(
'/sets/:prefix',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Delete an icon set',
description:
'Deletes the set and every icon stored for it, and drops its disk cache. Content still referencing those icons stops rendering them — disable the set instead to keep serving what is already in use.',
tags: ['Icons'],
params: {
type: 'object',
properties: {
prefix: {
type: 'string',
maxLength: 64
}
},
required: ['prefix']
},
response: {
200: {
description: 'Icon set deleted successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
deletedIcons: {
type: 'integer'
}
}
}
}
}
},
async (req, reply) => {
const prefix = req.params.prefix.toLowerCase()
if (!(await WIKI.models.icons.getSet(prefix))) {
return reply.notFound('Icon set has not been added.')
}
const deletedIcons = await WIKI.models.icons.deleteSet(prefix)
return {
ok: true,
message: `The ${prefix} icon set has been deleted.`,
deletedIcons
}
}
)
/**
* LIST ICON SETS AVAILABLE UPSTREAM
*/
app.get(
'/available-sets',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List the icon sets offered by the Iconify API',
description:
'The catalog an administrator picks from, marking the sets already added. Fetched from upstream and memoized for an hour, so it needs outbound access.',
tags: ['Icons'],
response: {
200: {
description: 'List of available icon sets',
type: 'array',
items: { $ref: 'AvailableIconSet#' }
}
}
}
},
async (_req, reply) => {
try {
return await WIKI.models.icons.getAvailableSets()
} catch (err: any) {
WIKI.logger.warn(err.message)
return reply.badGateway(`Could not reach the Iconify API: ${err.message}`)
}
}
)
/**
* REFRESH ICON SET METADATA
*/
app.post(
'/sets/refresh',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Refresh the metadata of every added icon set',
description:
'Re-reads names, totals and licenses from upstream. Stored icons are untouched.',
tags: ['Icons'],
response: {
200: {
description: 'Icon sets refreshed successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
refreshed: {
type: 'integer'
}
}
}
}
}
},
async (_req, reply) => {
try {
const refreshed = await WIKI.models.icons.refreshSets()
return {
ok: true,
message: `Refreshed ${refreshed} icon sets.`,
refreshed
}
} catch (err: any) {
WIKI.logger.warn(err.message)
return reply.badGateway(`Could not reach the Iconify API: ${err.message}`)
}
}
)
/**
* SEARCH ICONS
*/
app.get<{ Querystring: { query: string; prefixes?: string; limit?: number } }>(
'/search',
{
config: {
permissions: PICKER_PERMISSIONS
},
schema: {
summary: 'Search icons across the enabled icon sets',
description:
'Searched upstream, then narrowed to the sets enabled here — so results are always icons that can actually be used. Returns references shaped `prefix:name`, which is what content stores.',
tags: ['Icons'],
querystring: {
type: 'object',
required: ['query'],
properties: {
query: {
type: 'string',
minLength: 2,
maxLength: 128
},
prefixes: {
type: 'string',
description:
'Comma-separated set prefixes to search in. Defaults to every enabled set.'
},
limit: {
type: 'integer',
minimum: 32,
maximum: 999,
default: 96
}
}
},
response: {
200: {
description: 'Matching icon references',
type: 'object',
properties: {
icons: {
type: 'array',
items: {
type: 'string'
}
}
}
}
}
}
},
async (req, reply) => {
try {
const icons = await WIKI.models.icons.searchIcons({
query: req.query.query,
prefixes: req.query.prefixes?.split(',').filter(Boolean),
limit: req.query.limit
})
return { icons }
} catch (err: any) {
WIKI.logger.warn(err.message)
return reply.badGateway(`Could not reach the Iconify API: ${err.message}`)
}
}
)
/**
* LIST THE ICONS OF ONE SET
*/
app.get<{ Params: { prefix: string } }>(
'/sets/:prefix/icons',
{
config: {
permissions: PICKER_PERMISSIONS
},
schema: {
summary: 'List every icon name in an enabled set',
description:
'For browsing a set with no search term. Deprecated icons are left out. Fetched from upstream and memoized for an hour.',
tags: ['Icons'],
params: {
type: 'object',
properties: {
prefix: {
type: 'string',
maxLength: 64
}
},
required: ['prefix']
},
response: {
200: {
description: 'Icon names, without the set prefix',
type: 'object',
properties: {
prefix: {
type: 'string'
},
icons: {
type: 'array',
items: {
type: 'string'
}
}
}
}
}
}
},
async (req, reply) => {
const prefix = req.params.prefix.toLowerCase()
try {
return { prefix, icons: await WIKI.models.icons.listSetIcons(prefix) }
} catch (err: any) {
WIKI.logger.warn(err.message)
return reply.badRequest(err.message)
}
}
)
/**
* MATERIALIZE ICONS
*/
app.post<{ Body: { icons: string[] } }>(
'/materialize',
{
config: {
permissions: PICKER_PERMISSIONS
},
schema: {
summary: 'Store icons so this instance can serve them',
description:
'Called when an icon is chosen, while the author is online: it fetches the icon from upstream and writes it to the database, after which the wiki serves it forever without the Iconify API. Icons already stored are a no-op.',
tags: ['Icons'],
body: {
type: 'object',
required: ['icons'],
properties: {
icons: {
type: 'array',
minItems: 1,
maxItems: 128,
items: {
type: 'string',
maxLength: 320,
description: 'An icon reference shaped `prefix:name`.'
}
}
}
},
response: {
200: {
description: 'Icons materialized',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
failed: {
type: 'array',
items: {
type: 'string'
},
description:
'References that could not be stored: malformed, from a set that is not enabled, or unknown upstream.'
}
}
}
}
}
},
async (req) => {
const failed = await WIKI.models.icons.materializeIcons(req.body.icons)
return {
ok: failed.length < 1,
message:
failed.length < 1
? 'Icons are stored and ready to be served.'
: `${failed.length} of ${req.body.icons.length} icons could not be stored.`,
failed
}
}
)
/**
* ICON CACHE STATE
*/
app.get(
'/cache',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Report what this instance holds and has cached',
description:
'`iconCount` is permanent (database), the rest is this instances cache and can be discarded at any time.',
tags: ['Icons'],
response: {
200: {
description: 'Icon storage and cache state',
type: 'object',
properties: {
setCount: {
type: 'integer'
},
enabledSetCount: {
type: 'integer'
},
iconCount: {
type: 'integer'
},
memoryCount: {
type: 'integer'
},
diskCount: {
type: 'integer'
},
diskSize: {
type: 'integer',
description: 'Bytes held by the SVG files in the disk cache.'
}
}
}
}
}
},
async () => {
return WIKI.models.icons.getStats()
}
)
/**
* PURGE ICON CACHE
*/
app.delete(
'/cache',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Purge the icon cache of this instance',
description:
'Empties the memory and disk caches. Nothing is lost — both are rebuilt from the database as icons are requested again.',
tags: ['Icons'],
response: {
200: {
description: 'Cache purged successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async () => {
await WIKI.models.icons.purgeCache()
return {
ok: true,
message: 'The icon cache has been purged.'
}
}
)
}
export default routes

@ -12,10 +12,12 @@ async function routes(app: FastifyInstance) {
await import('./schemas/flags.ts').then((m) => m.registerSchemas(app))
await import('./schemas/group.ts').then((m) => m.registerSchemas(app))
await import('./schemas/hook.ts').then((m) => m.registerSchemas(app))
await import('./schemas/icon.ts').then((m) => m.registerSchemas(app))
await import('./schemas/mail.ts').then((m) => m.registerSchemas(app))
await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app))
await import('./schemas/security.ts').then((m) => m.registerSchemas(app))
await import('./schemas/site.ts').then((m) => m.registerSchemas(app))
await import('./schemas/storage.ts').then((m) => m.registerSchemas(app))
await import('./schemas/user.ts').then((m) => m.registerSchemas(app))
// Register routes
@ -24,11 +26,13 @@ async function routes(app: FastifyInstance) {
app.register(import('./blocks.ts'))
app.register(import('./groups.ts'), { prefix: '/groups' })
app.register(import('./hooks.ts'), { prefix: '/hooks' })
app.register(import('./icons.ts'), { prefix: '/icons' })
app.register(import('./locales.ts'), { prefix: '/locales' })
app.register(import('./mail.ts'), { prefix: '/mail' })
app.register(import('./pages.ts'))
app.register(import('./scheduler.ts'), { prefix: '/scheduler' })
app.register(import('./sites.ts'), { prefix: '/sites' })
app.register(import('./storage.ts'))
app.register(import('./system.ts'), { prefix: '/system' })
app.register(import('./users.ts'), { prefix: '/users' })
}

@ -0,0 +1,87 @@
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* ICON SET - An Iconify icon set added to this wiki
*/
app.addSchema({
$id: 'IconSet',
type: 'object',
properties: {
prefix: {
type: 'string',
description: 'Iconify prefix, i.e. the part before the colon in `mdi:account-edit`.'
},
name: {
type: 'string'
},
isEnabled: {
type: 'boolean',
description:
'A disabled set is not searchable and takes on no new icons, but the icons already stored for it keep being served so that published content does not break.'
},
info: {
type: 'object',
additionalProperties: true,
description:
'Iconify collection metadata (author, license, total, palette, samples, …) as published upstream. Empty until the first metadata refresh, which needs outbound access.'
},
iconCount: {
type: 'integer',
description:
'Icons of this set stored in the database, i.e. what this instance can serve without the upstream API.'
},
refreshedAt: {
type: 'string',
nullable: true
},
createdAt: {
type: 'string'
}
}
})
/**
* AVAILABLE ICON SET - A set offered upstream, whether or not it is added here
*/
app.addSchema({
$id: 'AvailableIconSet',
type: 'object',
properties: {
prefix: {
type: 'string'
},
name: {
type: 'string'
},
total: {
type: 'integer',
description: 'How many icons the set holds upstream.'
},
author: {
type: 'string'
},
license: {
type: 'string'
},
category: {
type: 'string'
},
palette: {
type: 'boolean',
description:
'Whether the icons carry their own colors, in which case they cannot be recolored.'
},
samples: {
type: 'array',
items: {
type: 'string'
},
description: 'A few icon names from the set, for a preview.'
},
isAdded: {
type: 'boolean'
}
}
})
}

@ -0,0 +1,217 @@
import { CONTENT_TYPES } from '../../models/storage.ts'
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* STORAGE TARGET - A storage module as configured for a site
*/
app.addSchema({
$id: 'StorageTarget',
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
module: {
type: 'string',
description: 'Directory name under `modules/storage`.'
},
isEnabled: {
type: 'boolean'
},
title: {
type: 'string'
},
description: {
type: 'string'
},
icon: {
type: 'string'
},
banner: {
type: 'string'
},
vendor: {
type: 'string'
},
website: {
type: 'string'
},
contentTypes: {
type: 'object',
description: 'Which kinds of content this target holds.',
properties: {
activeTypes: {
type: 'array',
items: {
type: 'string',
enum: [...CONTENT_TYPES]
}
},
largeThreshold: {
type: 'string',
description: 'Size above which an asset counts as a large file, e.g. `5MB`.'
}
}
},
assetDelivery: {
type: 'object',
description:
'How assets reach the user. The `is*Supported` flags come from the module and are read-only.',
properties: {
isStreamingSupported: {
type: 'boolean'
},
isDirectAccessSupported: {
type: 'boolean'
},
streaming: {
type: 'boolean'
},
directAccess: {
type: 'boolean'
}
}
},
versioning: {
type: 'object',
description:
'Whether past versions are kept. `isForceEnabled` marks a module where versioning is inherent, such as git.',
properties: {
isSupported: {
type: 'boolean'
},
isForceEnabled: {
type: 'boolean'
},
enabled: {
type: 'boolean'
}
}
},
setup: {
type: 'object',
description:
'Only present for a module that has a setup process and an implementation to run it.',
properties: {
handler: {
type: 'string',
description: 'Which setup flow the admin area should walk through, e.g. `github`.'
},
state: {
type: 'string',
enum: ['notconfigured', 'pendinginstall', 'configured']
},
values: {
type: 'object',
additionalProperties: true,
description: 'Values the setup form starts from.'
}
}
},
props: {
type: 'object',
additionalProperties: true,
description:
'The module configuration, declared in its `definition.yml`: each entry carries a `type`, `title`, `hint`, `default` and the display hints the admin area renders a control from. A `readOnly` prop is shown but cannot be changed, and is silently kept at its stored value when written to.'
},
config: {
type: 'object',
additionalProperties: true,
description:
'Values for the module props, completed with the module defaults for any prop that has none stored yet.'
},
actions: {
type: 'array',
description:
'Operations that can be run on demand. Empty for a module without an implementation, since there would be nothing to run.',
items: {
type: 'object',
properties: {
handler: {
type: 'string'
},
label: {
type: 'string'
},
hint: {
type: 'string'
},
warn: {
type: 'string',
description: 'Present when the action destroys data.'
},
icon: {
type: 'string'
}
}
}
}
}
})
/**
* STORAGE TARGET INPUT - A partial update of one target
*/
app.addSchema({
$id: 'StorageTargetInput',
type: 'object',
required: ['id'],
properties: {
id: {
type: 'string',
format: 'uuid'
},
isEnabled: {
type: 'boolean',
description:
'The database target cannot be disabled, and a target with a pending setup cannot be enabled.'
},
contentTypes: {
type: 'object',
properties: {
activeTypes: {
type: 'array',
items: {
type: 'string',
enum: [...CONTENT_TYPES]
}
},
largeThreshold: {
type: 'string',
maxLength: 32
}
}
},
assetDelivery: {
type: 'object',
description: 'A delivery mode the module does not support is stored as off.',
properties: {
streaming: {
type: 'boolean'
},
directAccess: {
type: 'boolean'
}
}
},
versioning: {
type: 'object',
description:
'Ignored by a module that does not support versioning or that forces it on — the module decides, not the client.',
properties: {
enabled: {
type: 'boolean'
}
}
},
config: {
type: 'object',
additionalProperties: true,
description:
'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value.'
}
}
})
}

@ -0,0 +1,380 @@
import type { FastifyInstance } from 'fastify'
import type { StorageTargetInput } from '../models/storage.ts'
/**
* Storage API Routes
*/
async function routes(app: FastifyInstance) {
/**
* LIST SITE STORAGE TARGETS
*/
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/storage/targets',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List the storage targets of a site',
description:
'One target per storage module installed in `modules/storage`, whether or not it has ever been enabled. Configuration values include any credentials a module stores, hence the `manage:system` requirement. Note that no module ships an implementation yet: a target holds configuration, and nothing reads or writes content through it.',
tags: ['Storage'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
response: {
200: {
description: 'List of storage targets',
type: 'array',
items: { $ref: 'StorageTarget#' }
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
return WIKI.models.storage.getSiteTargets(req.params.siteId)
}
)
/**
* UPDATE SITE STORAGE TARGETS
*/
app.put<{ Params: { siteId: string }; Body: { targets: StorageTargetInput[] } }>(
'/sites/:siteId/storage/targets',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update the storage targets of a site',
description:
'Only the targets listed are affected, and within each of them only the fields provided. Every target is validated before any of them is written, so a rejected request changes nothing.',
tags: ['Storage'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
body: {
type: 'object',
required: ['targets'],
properties: {
targets: {
type: 'array',
items: { $ref: 'StorageTargetInput#' }
}
}
},
response: {
200: {
description: 'Storage targets updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
updated: {
type: 'integer',
description:
'How many target rows were written. A target already in the requested state still counts.'
}
}
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
// -> Validated as a whole first: a partially applied storage configuration is worse than a
// refused one, since the admin area saves every target at once
const current = await WIKI.models.storage.getSiteTargets(req.params.siteId)
const patches = []
for (const patch of req.body.targets) {
const target = current.find((t) => t.id === patch.id)
if (!target) {
return reply.notFound(`Storage target ${patch.id} does not exist.`)
}
const invalid = WIKI.models.storage.validateTarget(target, patch)
if (invalid) {
return reply.badRequest(invalid)
}
patches.push({ target, patch })
}
let updated = 0
for (const { target, patch } of patches) {
if (await WIKI.models.storage.updateTarget(req.params.siteId, target, patch)) {
updated++
}
}
return {
ok: true,
message: 'Storage targets updated successfully.',
updated
}
}
)
/**
* EXECUTE STORAGE TARGET ACTION
*/
app.post<{ Params: { siteId: string; targetId: string; action: string } }>(
'/sites/:siteId/storage/targets/:targetId/actions/:action',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Run an action on a storage target',
description:
'The actions a target offers are listed with it. Only an enabled target can run one, and only a module with an implementation offers any — so every action currently fails, no module having one yet.',
tags: ['Storage'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
targetId: {
type: 'string',
format: 'uuid'
},
action: {
type: 'string',
maxLength: 255
}
},
required: ['siteId', 'targetId', 'action']
},
response: {
200: {
description: 'Action completed successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const target = await WIKI.models.storage.getSiteTargetById(
req.params.siteId,
req.params.targetId
)
if (!target) {
return reply.notFound('Storage target does not exist.')
}
if (!target.isEnabled) {
return reply.conflict('The storage target must be enabled before running an action.')
}
if (!target.actions.some((act) => act.handler === req.params.action)) {
return reply.badRequest(`${target.title} has no "${req.params.action}" action.`)
}
try {
await WIKI.models.storage.executeAction(target, req.params.action)
} catch (err: any) {
WIKI.logger.warn(err)
return reply.badRequest(err.message)
}
return {
ok: true,
message: 'Action completed successfully.'
}
}
)
/**
* RUN STORAGE TARGET SETUP STEP
*/
app.post<{
Params: { siteId: string; targetId: string }
Body: Record<string, any>
}>(
'/sites/:siteId/storage/targets/:targetId/setup',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Advance the setup process of a storage target',
description:
'For modules that cannot be configured by hand, such as one backed by an app installed on a provider. The body is passed to the module as-is, and what comes back tells the admin area what to do next. Only a module with an implementation has a setup process — none does yet.',
tags: ['Storage'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
targetId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId', 'targetId']
},
body: {
type: 'object',
required: ['step'],
additionalProperties: true,
properties: {
step: {
type: 'string',
maxLength: 255,
description: 'Which step of the process to run, as named by the module.'
}
}
},
response: {
200: {
description: 'Setup step completed successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
state: {
type: 'object',
additionalProperties: true,
description: 'What the module wants done next, e.g. `{ nextStep, url }`.'
}
}
}
}
}
},
async (req, reply) => {
const target = await WIKI.models.storage.getSiteTargetById(
req.params.siteId,
req.params.targetId
)
if (!target) {
return reply.notFound('Storage target does not exist.')
}
if (!target.setup) {
return reply.badRequest(`${target.title} has no setup process.`)
}
try {
const state = await WIKI.models.storage.runSetup(target, req.body)
return {
ok: true,
message: 'Setup step completed successfully.',
state
}
} catch (err: any) {
WIKI.logger.warn(err)
return reply.badRequest(err.message)
}
}
)
/**
* DESTROY STORAGE TARGET SETUP
*/
app.delete<{ Params: { siteId: string; targetId: string } }>(
'/sites/:siteId/storage/targets/:targetId/setup',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Reset the setup of a storage target',
description:
'Undoes what the setup process configured, so that it can be started over. What that involves is up to the module.',
tags: ['Storage'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
targetId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId', 'targetId']
},
response: {
200: {
description: 'Setup reset successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const target = await WIKI.models.storage.getSiteTargetById(
req.params.siteId,
req.params.targetId
)
if (!target) {
return reply.notFound('Storage target does not exist.')
}
if (!target.setup) {
return reply.badRequest(`${target.title} has no setup process.`)
}
try {
await WIKI.models.storage.destroySetup(target)
} catch (err: any) {
WIKI.logger.warn(err)
return reply.badRequest(err.message)
}
return {
ok: true,
message: 'Setup reset successfully.'
}
}
)
}
export default routes

@ -27,6 +27,10 @@ defaults:
offline: false
dataPath: ./data
bodyParserLimit: 5mb
icons:
# Iconify API the wiki fetches icons from the first time they are used. Point this at a
# self-hosted Iconify API to keep icon lookups inside your network.
apiUrl: 'https://api.iconify.design'
scheduler:
workers: 3
pollingCheck: 5

@ -0,0 +1,112 @@
import { generateHash } from '../helpers/common.ts'
import type { FastifyInstance, FastifyReply } from 'fastify'
/** Ceiling on how many icons one batch request may ask for. */
const MAX_ICONS_PER_REQUEST = 128
/** An icon never changes under a given name, so the answer can be cached as hard as HTTP allows. */
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
/** Long enough that a page's icons are asked for once, short enough to pick up new sets. */
const BATCH_CACHE = 'public, max-age=604800'
/** A batch that came back incomplete is worth asking about again soon. */
const INCOMPLETE_CACHE = 'public, max-age=60'
/**
* Answer with a body only when the client does not already have it.
*
* Icons are immutable and served for a year, so this only matters for the client that arrives without
* a warm HTTP cache but with a stale one cheap enough to be worth the few lines.
*/
function sendCacheable(
reply: FastifyReply,
ifNoneMatch: string | undefined,
body: string,
{ contentType, cacheControl }: { contentType: string; cacheControl: string }
): FastifyReply {
const etag = `"${generateHash(body)}"`
reply.header('ETag', etag)
reply.header('Cache-Control', cacheControl)
if (ifNoneMatch === etag) {
return reply.code(304).send()
}
return reply.type(contentType).send(body)
}
/**
* _icons Routes
*
* Implements the part of the Iconify API protocol the frontend uses, so that `iconify-icon` and any
* other Iconify client can be pointed at this wiki instead of a third-party host: content references
* `mdi:account-edit`, the browser asks this route for it, and nothing about which icons a reader looks
* at leaves the instance.
*
* Public on purpose icons are page furniture, and a reader who can see a page can see its icons.
* The routes only serve what the wiki holds or can fill in for an enabled set, and filling is bounded
* by the model's upstream budget.
*/
async function routes(app: FastifyInstance) {
/**
* BATCH ICON DATA what `iconify-icon` requests, one call per set per page
*/
app.get<{ Params: { prefix: string }; Querystring: { icons?: string } }>(
'/:prefix.json',
async (req, reply) => {
const prefix = req.params.prefix.toLowerCase()
const names = (req.query.icons ?? '')
.split(',')
.map((name) => name.trim().toLowerCase())
.filter(Boolean)
.slice(0, MAX_ICONS_PER_REQUEST)
if (names.length < 1) {
return reply.badRequest('No icons requested.')
}
const set = await WIKI.models.icons.getSet(prefix)
if (!set) {
return reply.notFound('Icon set not found.')
}
const resolved = await WIKI.models.icons.resolveIcons(prefix, names)
const payload = {
prefix,
icons: resolved.icons,
...(resolved.notFound.length > 0 && { not_found: resolved.notFound })
}
return sendCacheable(reply, req.headers['if-none-match'], JSON.stringify(payload), {
contentType: 'application/json; charset=utf-8',
cacheControl: resolved.notFound.length > 0 ? INCOMPLETE_CACHE : BATCH_CACHE
})
}
)
/**
* SINGLE ICON AS SVG for `<img>` and CSS, where a URL is all that fits
*/
app.get<{ Params: { prefix: string; name: string } }>(
'/:prefix/:name.svg',
async (req, reply) => {
const svg = await WIKI.models.icons.getIconSvg(
req.params.prefix.toLowerCase(),
req.params.name.toLowerCase()
)
if (!svg) {
return reply.notFound('Icon not found.')
}
// -> The markup comes from a third party and is served from our own origin, so it is locked down
// for the case where it is opened as a document rather than drawn as an image
reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'")
reply.header('X-Content-Type-Options', 'nosniff')
return sendCacheable(reply, req.headers['if-none-match'], svg, {
contentType: 'image/svg+xml; charset=utf-8',
cacheControl: IMMUTABLE_CACHE
})
}
)
}
export default routes

@ -158,6 +158,7 @@ export default {
await WIKI.models.authentication.init(ids)
await WIKI.models.users.init(ids)
await WIKI.models.jobs.init()
await WIKI.models.icons.init()
},
/**
* Subscribe to HA propagation events

@ -0,0 +1,14 @@
CREATE TABLE "storage" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"module" varchar(255) NOT NULL,
"isEnabled" boolean DEFAULT false NOT NULL,
"contentTypes" jsonb DEFAULT '{}' NOT NULL,
"assetDelivery" jsonb DEFAULT '{}' NOT NULL,
"versioning" jsonb DEFAULT '{}' NOT NULL,
"config" jsonb DEFAULT '{}' NOT NULL,
"state" jsonb DEFAULT '{}' NOT NULL,
"siteId" uuid NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "storage_composite_idx" ON "storage" ("siteId","module");--> statement-breakpoint
ALTER TABLE "storage" ADD CONSTRAINT "storage_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");

File diff suppressed because it is too large Load Diff

@ -0,0 +1,25 @@
CREATE TABLE "iconSets" (
"prefix" varchar(64) PRIMARY KEY,
"name" varchar(255) NOT NULL,
"isEnabled" boolean DEFAULT true NOT NULL,
"info" jsonb DEFAULT '{}' NOT NULL,
"refreshedAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "icons" (
"prefix" varchar(64),
"name" varchar(255),
"body" text NOT NULL,
"width" integer DEFAULT 16 NOT NULL,
"height" integer DEFAULT 16 NOT NULL,
"left" integer DEFAULT 0 NOT NULL,
"top" integer DEFAULT 0 NOT NULL,
"rotate" integer DEFAULT 0 NOT NULL,
"hFlip" boolean DEFAULT false NOT NULL,
"vFlip" boolean DEFAULT false NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "icons_pkey" PRIMARY KEY("prefix","name")
);
--> statement-breakpoint
ALTER TABLE "icons" ADD CONSTRAINT "icons_prefix_iconSets_prefix_fkey" FOREIGN KEY ("prefix") REFERENCES "iconSets"("prefix");

File diff suppressed because it is too large Load Diff

@ -145,6 +145,46 @@ export const hooks = pgTable('hooks', {
updatedAt: timestamp().notNull().defaultNow()
})
// ICONS -------------------------------
// -> An Iconify icon set the wiki draws icons from, e.g. `mdi`. Adding one makes its icons
// searchable; individual icons are only stored once something references them.
export const iconSets = pgTable('iconSets', {
// -> The Iconify prefix, which is what content references: `<prefix>:<name>`
prefix: varchar({ length: 64 }).primaryKey(),
name: varchar({ length: 255 }).notNull(),
isEnabled: boolean().notNull().default(true),
// -> Iconify collection metadata (author, license, total, palette, samples, ...) as published by
// the upstream API, refreshed on demand rather than being authored here
info: jsonb().notNull().default({}),
refreshedAt: timestamp(),
createdAt: timestamp().notNull().defaultNow()
})
// -> The permanent home of every icon the wiki has ever served. Fetched from the Iconify API on first
// use, then never fetched again: the disk cache is derived from these rows and may be empty.
export const icons = pgTable(
'icons',
{
prefix: varchar({ length: 64 })
.notNull()
.references(() => iconSets.prefix),
name: varchar({ length: 255 }).notNull(),
// -> The SVG markup inside the `<svg>` element, with `currentColor` left as-is
body: text().notNull(),
// -> Resolved Iconify icon properties: the viewBox is `left top width height`, and the transform
// flags apply on top of it. Aliases are resolved before storing, so a row is self-contained.
width: integer().notNull().default(16),
height: integer().notNull().default(16),
left: integer().notNull().default(0),
top: integer().notNull().default(0),
rotate: integer().notNull().default(0),
hFlip: boolean().notNull().default(false),
vFlip: boolean().notNull().default(false),
createdAt: timestamp().notNull().defaultNow()
},
(table) => [primaryKey({ columns: [table.prefix, table.name] })]
)
// JOB HISTORY -------------------------
export const jobHistoryStateEnum = pgEnum('jobHistoryState', [
'active',
@ -328,6 +368,33 @@ export const sites = pgTable('sites', {
createdAt: timestamp().notNull().defaultNow()
})
// STORAGE -----------------------------
export const storage = pgTable(
'storage',
{
id: uuid().primaryKey().defaultRandom(),
// -> Directory name under `modules/storage`, one row per module per site
module: varchar({ length: 255 }).notNull(),
isEnabled: boolean().notNull().default(false),
// -> `{ activeTypes: string[], largeThreshold: string }`
contentTypes: jsonb().notNull().default({}),
// -> `{ streaming: boolean, directAccess: boolean }`
assetDelivery: jsonb().notNull().default({}),
// -> `{ enabled: boolean }`
versioning: jsonb().notNull().default({}),
// -> Values for the props the module declares in its `definition.yml`
config: jsonb().notNull().default({}),
// -> Where the module stands, as opposed to how it is configured: `{ setup: 'notconfigured' |
// 'pendinginstall' | 'configured' }` for a module that has a setup process to go through.
state: jsonb().notNull().default({}),
siteId: uuid()
.notNull()
.references(() => sites.id)
},
// -> Covers lookups by site as well, being the leading column
(table) => [uniqueIndex('storage_composite_idx').on(table.siteId, table.module)]
)
// TAGS --------------------------------
export const tags = pgTable(
'tags',

@ -61,11 +61,7 @@ const WIKI = {
configSvc,
sites: {},
sitesMappings: {},
startedAt: Temporal.Now.instant(),
storage: {
defs: [],
modules: []
}
startedAt: Temporal.Now.instant()
} as unknown as WikiGlobal
global.WIKI = WIKI
@ -146,11 +142,18 @@ async function postBoot() {
await WIKI.models.blocks.refreshFromDisk()
await WIKI.models.blocks.syncAllSites()
// -> Same: every site gets a row per installed storage module
await WIKI.models.storage.refreshFromDisk()
await WIKI.models.storage.syncAllSites()
// -> Optional third-party tooling: report what is available, since features silently degrade
// without it
await WIKI.models.extensions.refreshFromDisk()
await WIKI.models.extensions.logState()
// -> The icon cache is derived from the db and starts empty on a fresh instance
await WIKI.models.icons.ensureCacheDir()
await WIKI.dbManager.subscribeToNotifications()
await WIKI.scheduler.start()
}
@ -546,6 +549,7 @@ async function initHTTPServer() {
app.register(import('./api/index.ts'), { prefix: '/_api' })
app.register(import('./controllers/site.ts'), { prefix: '/_site' })
app.register(import('./controllers/icons.ts'), { prefix: '/_icons' })
// ----------------------------------------
// Error handling

@ -403,12 +403,48 @@
"admin.groups.users": "Users",
"admin.groups.usersCount": "0 user | 1 user | {count} users",
"admin.groups.usersNone": "This group doesn't have any user yet.",
"admin.icons.mandatory": "Used by the system and cannot be disabled.",
"admin.icons.addFailed": "Failed to add the icon set.",
"admin.icons.addSet": "Add Icon Set",
"admin.icons.addSetHint": "Pick an icon set to make its icons available. Only the set description is downloaded — individual icons are fetched and stored the first time they are used.",
"admin.icons.addSuccess": "The {set} icon set has been added.",
"admin.icons.added": "Added",
"admin.icons.deleteFailed": "Failed to delete the icon set.",
"admin.icons.deleteSet": "Delete Icon Set",
"admin.icons.deleteSetConfirm": "Delete the {set} icon set and the {count} icons stored for it? Content still referencing those icons will stop showing them. Disable the set instead to keep serving the icons already in use.",
"admin.icons.deleteSuccess": "The {set} icon set has been deleted.",
"admin.icons.disableSuccess": "The {set} icon set has been disabled.",
"admin.icons.diskCache": "Disk cache",
"admin.icons.diskCacheValue": "{count} icons ({size})",
"admin.icons.enableSuccess": "The {set} icon set has been enabled.",
"admin.icons.filterSets": "Filter icon sets...",
"admin.icons.howItWorks": "How icons are stored",
"admin.icons.howItWorksHint": "Content references an icon by name, e.g. mdi:account-edit. The first time an icon is used it is fetched from Iconify and saved to the database, which is its permanent home. Memory and disk are caches in front of it and can be discarded at any time.",
"admin.icons.isEnabled": "Enabled",
"admin.icons.loadFailed": "Failed to load the icon sets.",
"admin.icons.memoryCache": "Memory cache",
"admin.icons.memoryCacheValue": "{count} icons on this instance",
"admin.icons.noSets": "No icon set has been added yet. Add one to start using icons.",
"admin.icons.paletteWarn": "This set has fixed colors and cannot be recolored.",
"admin.icons.purgeCache": "Purge Cache",
"admin.icons.purgeCacheConfirm": "Empty the memory and disk caches of this instance? Nothing is lost — both are rebuilt from the database as icons are requested again.",
"admin.icons.purgeCacheFailed": "Failed to purge the icon cache.",
"admin.icons.purgeCacheHint": "Empties this instances caches. The stored icons are unaffected.",
"admin.icons.purgeCacheSuccess": "The icon cache has been purged.",
"admin.icons.reference": "Reference",
"admin.icons.subtitle": "Configure the icon packs available for use",
"admin.icons.referenceHint": "View every icon in this set, with its name, on Iconify.",
"admin.icons.saveFailed": "Failed to save the icon set.",
"admin.icons.setIconCount": "{count} icons stored",
"admin.icons.setTotal": "{total} available",
"admin.icons.sets": "Icon Sets",
"admin.icons.setsHint": "Icons from an enabled set can be searched and used across the wiki.",
"admin.icons.storage": "Storage",
"admin.icons.storageHint": "What this wiki holds, and what this instance has cached.",
"admin.icons.storedIcons": "Stored icons",
"admin.icons.storedIconsValue": "{count} icons in the database",
"admin.icons.subtitle": "Choose which icon sets can be used across the wiki",
"admin.icons.title": "Icons",
"admin.icons.warnHint": "Only activate the icon packs you actually use.",
"admin.icons.warnLabel": "Enabling additional icon packs can significantly increase page load times!",
"admin.icons.upstream": "Icon source",
"admin.icons.upstreamHint": "Icons come from the Iconify API. Once stored, they are served by this wiki and never fetched again — readers never talk to Iconify.",
"admin.instances.activeConnections": "Active Connections",
"admin.instances.activeListeners": "Active Listeners",
"admin.instances.firstSeen": "First Seen",
@ -736,7 +772,9 @@
"admin.ssl.title": "SSL",
"admin.ssl.writableConfigFileWarning": "Note that your config file must be writable in order to persist ports configuration.",
"admin.stats.title": "Statistics",
"admin.storage.actionFailed": "Failed to run {action}.",
"admin.storage.actionRun": "Run",
"admin.storage.actionSuccess": "{action} completed successfully.",
"admin.storage.actions": "Actions",
"admin.storage.actionsInactiveWarn": "You must enable this storage target and apply changes before you can run actions.",
"admin.storage.addTarget": "Add Storage Target",
@ -806,6 +844,7 @@
"admin.storage.inactiveTarget": "Inactive",
"admin.storage.lastSync": "Last synchronization {time}",
"admin.storage.lastSyncAttempt": "Last attempt was {time}",
"admin.storage.loadFailed": "Failed to load storage configuration.",
"admin.storage.missingOrigin": "Missing Origin",
"admin.storage.noActions": "This storage target has no actions that you can execute.",
"admin.storage.noConfigOption": "This storage target has no configuration options you can modify.",
@ -1818,6 +1857,19 @@
"history.restore.confirmText": "Are you sure you want to restore this page content as it was on {date}? This version will be copied on top of the current history. As such, newer versions will still be preserved.",
"history.restore.confirmTitle": "Restore page version?",
"history.restore.success": "Page version restored succesfully!",
"iconPicker.allSets": "All sets",
"iconPicker.custom": "Custom",
"iconPicker.customHint": "Any other icon reference, such as a webfont name (las la-home) or an image URL (img:/path/to/icon.svg).",
"iconPicker.icons": "Icons",
"iconPicker.materializeFailed": "Could not store {icon} for offline use.",
"iconPicker.noResults": "No icon matches your search.",
"iconPicker.reference": "Icon reference",
"iconPicker.search": "Search icons...",
"iconPicker.searchFailed": "Icon search failed.",
"iconPicker.searchHint": "Type at least 2 characters to search icons.",
"iconPicker.selection": "Selected icon",
"iconPicker.set": "Set",
"iconPicker.setsFailed": "Failed to load the icon sets.",
"navEdit.clearItems": "Clear All Items",
"navEdit.editMenuItems": "Edit Menu Items",
"navEdit.emptyMenuText": "Click the Add button to add your first menu item.",

@ -0,0 +1,815 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { and, count, eq, inArray } from 'drizzle-orm'
import { getIconData, iconToHTML, iconToSVG } from '@iconify/utils'
import { icons as iconsTable, iconSets as iconSetsTable } from '../db/schema.ts'
import type { IconifyIcon, IconifyInfo, IconifyJSON } from '@iconify/types'
/** An icon set as stored, plus how many of its icons the wiki holds. */
export interface IconSet {
prefix: string
name: string
isEnabled: boolean
info: IconifyInfo | Record<string, never>
refreshedAt: Date | null
createdAt: Date
/** Icons of this set stored in the database, i.e. the ones this wiki can serve on its own. */
iconCount: number
}
/** An icon set offered by the upstream API but not added here yet. */
export interface AvailableIconSet {
prefix: string
name: string
total: number
author: string
license: string
category: string
/** Whether the set's icons carry their own colors, i.e. cannot be recolored with `currentColor`. */
palette: boolean
samples: string[]
isAdded: boolean
}
/** The result of a resolve, in the shape the Iconify API protocol expects. */
export interface ResolvedIcons {
icons: Record<string, IconifyIcon>
notFound: string[]
}
/**
* Icon sets seeded on a fresh instance, so that the picker is usable before an administrator has
* added anything. The names are the upstream ones and get overwritten by the first metadata refresh.
*/
const DEFAULT_SETS: { prefix: string; name: string }[] = [
{ prefix: 'mdi', name: 'Material Design Icons' },
{ prefix: 'la', name: 'Line Awesome' }
]
/** Iconify prefixes and icon names are lowercase, dash-separated words. */
const PREFIX_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const NAME_PATTERN = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/
/** How many resolved icons to hold per instance. An icon body is ~1 kB, so this is a few MB. */
const MEMORY_CACHE_MAX = 2000
/** How long the upstream collection list and per-set icon lists stay memoized. */
const CATALOG_TTL_MS = 60 * 60 * 1000
/**
* Ceiling on upstream requests per minute, across every caller.
*
* The public icon route fills the cache on a miss, and it is reachable by anyone who can read a page.
* Without a ceiling, a stream of requests for icons that do not exist would be amplified into a
* stream of requests to the Iconify API. Icons already stored are unaffected they never go upstream.
*/
const UPSTREAM_BUDGET_PER_MINUTE = 60
/** How long a name that upstream does not know stays remembered as missing. */
const NOT_FOUND_TTL_MS = 60 * 60 * 1000
/**
* Reject anything that could execute when an icon is opened directly rather than drawn into a page.
*
* Icon bodies come from a third-party API, and while Iconify publishes shape markup, a compromised or
* misconfigured upstream is exactly the case worth being defensive about. Nothing legitimate in an
* icon body needs a script, an event handler or an external reference.
*/
function isSafeIconBody(body: string): boolean {
return !/<script|<foreignobject|<iframe|<use[^>]+href\s*=\s*["']?https?:|\son\w+\s*=|javascript:/i.test(
body
)
}
/**
* Icons model
*
* Icons are addressed the way Iconify addresses them `<prefix>:<name>`, e.g. `mdi:account-edit`
* and that reference is all content ever stores. Resolving one to markup goes through four tiers:
*
* 1. **memory**, per instance, for the icons a page is actually made of
* 2. **disk**, under `<dataPath>/cache/icons`, one small JSON file per icon
* 3. **the database**, the permanent record: every icon the wiki has ever served lives here, so a new
* instance with an empty disk (or an instance with no outbound network at all) serves everything
* that content references
* 4. **the Iconify API**, consulted only for an icon nobody has used yet, and then persisted
*
* Rendering a page never resolves an icon: the page carries names, the browser asks for the icons it
* needs in one batch, and those answers are cached hard by the browser. Serving them touches the
* database only for an icon that is neither in memory nor on disk so on a warm instance, never.
*/
class Icons {
/** Resolved icon data, keyed `prefix:name`. Insertion-ordered, so the oldest entry is evictable. */
memoryCache = new Map<string, IconifyIcon>()
/** Names upstream has no icon for, keyed `prefix:name` with the time they were last looked up. */
notFoundCache = new Map<string, number>()
/** Upstream catalog responses, memoized to keep the admin area and the picker snappy. */
catalogCache = new Map<string, { fetchedAt: number; data: any }>()
/** Rolling count of upstream requests, for `UPSTREAM_BUDGET_PER_MINUTE`. */
upstreamBudget = { windowStartedAt: 0, used: 0 }
get cachePath(): string {
return path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'cache/icons')
}
get apiUrl(): string {
return WIKI.config.icons?.apiUrl || 'https://api.iconify.design'
}
/**
* Split a `prefix:name` reference, or null when it is not one
*/
parseRef(ref: string): { prefix: string; name: string } | null {
const [prefix, name, ...rest] = `${ref}`.toLowerCase().split(':')
if (!prefix || !name || rest.length > 0) {
return null
}
return this.isValidRef(prefix, name) ? { prefix, name } : null
}
/**
* Whether a prefix and name are shaped like Iconify identifiers.
*
* Both end up in a file path, so this is what keeps `../` and friends out of the disk cache.
*/
isValidRef(prefix: string, name: string): boolean {
return PREFIX_PATTERN.test(prefix) && NAME_PATTERN.test(name)
}
// == SETS ===========================
/**
* Every added icon set, alphabetically, with the number of icons stored for each
*/
async getSets(): Promise<IconSet[]> {
const sets = await WIKI.db.select().from(iconSetsTable).orderBy(iconSetsTable.name)
const counts = await WIKI.db
.select({ prefix: iconsTable.prefix, total: count() })
.from(iconsTable)
.groupBy(iconsTable.prefix)
return sets.map((set) => ({
...set,
info: (set.info ?? {}) as IconifyInfo,
iconCount: counts.find((c) => c.prefix === set.prefix)?.total ?? 0
})) as IconSet[]
}
/**
* A single set, or null when it has not been added
*/
async getSet(prefix: string): Promise<IconSet | null> {
return (await this.getSets()).find((set) => set.prefix === prefix) ?? null
}
/**
* The prefixes of the sets icons may currently be drawn from
*/
async getEnabledPrefixes(): Promise<string[]> {
const sets = await WIKI.db
.select({ prefix: iconSetsTable.prefix })
.from(iconSetsTable)
.where(eq(iconSetsTable.isEnabled, true))
return sets.map((s) => s.prefix)
}
/**
* Add an icon set, taking its name and metadata from upstream.
*
* @returns The set as added
* @throws When the prefix is malformed, already added, or unknown upstream
*/
async addSet(prefix: string): Promise<IconSet> {
if (!PREFIX_PATTERN.test(prefix)) {
return Promise.reject(new Error(`"${prefix}" is not a valid icon set prefix.`))
}
if (await this.getSet(prefix)) {
return Promise.reject(new Error(`The ${prefix} icon set has already been added.`))
}
const collections = await this.getCollections()
const info = collections[prefix]
if (!info) {
return Promise.reject(new Error(`There is no "${prefix}" icon set available upstream.`))
}
await WIKI.db.insert(iconSetsTable).values({
prefix,
name: info.name ?? prefix,
isEnabled: true,
info,
refreshedAt: new Date()
})
WIKI.logger.info(`Added icon set ${prefix} [ OK ]`)
return (await this.getSet(prefix))!
}
/**
* Enable or disable an icon set.
*
* A disabled set stops being searchable and stops being filled from upstream, but the icons already
* stored for it keep being served: content referencing them is already published, and answering
* those requests with nothing would silently break pages.
*
* @returns Whether the set was updated
*/
async setSetState(prefix: string, isEnabled: boolean): Promise<boolean> {
const result = await WIKI.db
.update(iconSetsTable)
.set({ isEnabled })
.where(eq(iconSetsTable.prefix, prefix))
return (result.rowCount ?? 0) > 0
}
/**
* Delete an icon set along with every icon stored for it, and drop its disk cache.
*
* Content referencing those icons will stop rendering them, which is why the admin area asks first.
*
* @returns How many stored icons went with it
*/
async deleteSet(prefix: string): Promise<number> {
const deletedIcons = await WIKI.db.delete(iconsTable).where(eq(iconsTable.prefix, prefix))
await WIKI.db.delete(iconSetsTable).where(eq(iconSetsTable.prefix, prefix))
for (const key of this.memoryCache.keys()) {
if (key.startsWith(`${prefix}:`)) {
this.memoryCache.delete(key)
}
}
await fs.rm(path.join(this.cachePath, prefix), { recursive: true, force: true })
WIKI.logger.info(`Deleted icon set ${prefix} [ OK ]`)
return deletedIcons.rowCount ?? 0
}
/**
* Re-read the metadata of every added set from upstream.
*
* Only the description of a set changes here its icons are untouched.
*
* @returns How many sets were refreshed
*/
async refreshSets(): Promise<number> {
const collections = await this.getCollections()
const sets = await WIKI.db.select({ prefix: iconSetsTable.prefix }).from(iconSetsTable)
let refreshed = 0
for (const set of sets) {
const info = collections[set.prefix]
if (!info) {
// -> A set can be renamed or withdrawn upstream. Keeping the row is the right call: its icons
// are stored here and content still references them.
WIKI.logger.warn(`Icon set ${set.prefix} is no longer offered upstream [ SKIPPED ]`)
continue
}
await WIKI.db
.update(iconSetsTable)
.set({ name: info.name ?? set.prefix, info, refreshedAt: new Date() })
.where(eq(iconSetsTable.prefix, set.prefix))
refreshed++
}
return refreshed
}
// == UPSTREAM CATALOG ===============
/**
* Every icon set the upstream API offers, keyed by prefix
*/
async getCollections(): Promise<Record<string, IconifyInfo>> {
return this.fetchCatalog('collections', '/collections')
}
/**
* The upstream catalog as the admin area lists it, marking the sets already added
*/
async getAvailableSets(): Promise<AvailableIconSet[]> {
const [collections, added] = await Promise.all([
this.getCollections(),
WIKI.db.select({ prefix: iconSetsTable.prefix }).from(iconSetsTable)
])
const addedPrefixes = added.map((s) => s.prefix)
return Object.entries(collections)
.map(([prefix, info]) => ({
prefix,
name: info.name ?? prefix,
total: info.total ?? 0,
author: info.author?.name ?? '',
license: info.license?.title ?? '',
category: info.category ?? '',
palette: info.palette === true,
samples: info.samples ?? [],
isAdded: addedPrefixes.includes(prefix)
}))
.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* The names of every icon in a set, for browsing it without a search term.
*
* @throws When the set has not been added or is disabled
*/
async listSetIcons(prefix: string): Promise<string[]> {
const set = await this.getSet(prefix)
if (!set?.isEnabled) {
return Promise.reject(new Error(`The ${prefix} icon set is not available.`))
}
const collection = await this.fetchCatalog(
`collection:${prefix}`,
`/collection?prefix=${encodeURIComponent(prefix)}`
)
// -> Icons come either grouped in categories or as a flat `uncategorized` list, and a set can use
// both. Hidden icons are deprecated ones kept for compatibility, so they are left out.
const categorized = Object.values(
(collection.categories ?? {}) as Record<string, string[]>
).flat()
const names = [...categorized, ...((collection.uncategorized ?? []) as string[])]
return [...new Set(names)].sort()
}
/**
* Search icons upstream, within the sets that are enabled here.
*
* @returns References shaped `prefix:name`
*/
async searchIcons({
query,
prefixes,
limit = 96
}: {
query: string
prefixes?: string[]
limit?: number
}): Promise<string[]> {
const enabled = await this.getEnabledPrefixes()
// -> Searching a disabled set would offer icons that cannot then be stored
const searchIn = prefixes?.length ? prefixes.filter((p) => enabled.includes(p)) : enabled
if (searchIn.length < 1) {
return []
}
const params = new URLSearchParams({
query,
limit: `${Math.min(Math.max(limit, 32), 999)}`,
prefixes: searchIn.join(',')
})
const result = await this.apiFetch(`/search?${params}`)
return (result.icons ?? []) as string[]
}
/**
* Fetch an upstream catalog response, memoized for `CATALOG_TTL_MS`.
*
* These are large and change rarely, whereas the admin area and the picker ask for them often.
*/
async fetchCatalog(key: string, pathname: string): Promise<any> {
const cached = this.catalogCache.get(key)
if (cached && Date.now() - cached.fetchedAt < CATALOG_TTL_MS) {
return cached.data
}
const data = await this.apiFetch(pathname)
this.catalogCache.set(key, { fetchedAt: Date.now(), data })
return data
}
// == RESOLVING ======================
/**
* Resolve icons of one set, filling the cache from upstream for any the wiki does not hold yet.
*
* @param allowUpstream Whether a miss may be fetched upstream. False for callers that must not
* cause outbound traffic, e.g. a bulk render.
*/
async resolveIcons(
prefix: string,
names: string[],
{ allowUpstream = true }: { allowUpstream?: boolean } = {}
): Promise<ResolvedIcons> {
const wanted = [...new Set(names)].filter((name) => this.isValidRef(prefix, name))
const icons: Record<string, IconifyIcon> = {}
const missing: string[] = []
// -> Memory first: the icons a page is made of are asked for again and again
for (const name of wanted) {
const cached = this.memoryCache.get(`${prefix}:${name}`)
if (cached) {
icons[name] = cached
} else {
missing.push(name)
}
}
if (missing.length < 1) {
return { icons, notFound: [] }
}
// -> Then disk, which survives a restart and is what keeps page views off the database
const stillMissingAfterDisk: string[] = []
for (const name of missing) {
const cached = await this.readDiskCache(prefix, name)
if (cached) {
this.remember(prefix, name, cached)
icons[name] = cached
} else {
stillMissingAfterDisk.push(name)
}
}
if (stillMissingAfterDisk.length < 1) {
return { icons, notFound: [] }
}
// -> Then the permanent record, in one query for everything still missing
const rows = await WIKI.db
.select()
.from(iconsTable)
.where(and(eq(iconsTable.prefix, prefix), inArray(iconsTable.name, stillMissingAfterDisk)))
for (const row of rows) {
const icon = this.rowToIcon(row)
this.remember(prefix, row.name, icon)
await this.writeDiskCache(prefix, row.name, icon)
icons[row.name] = icon
}
const stillMissing = stillMissingAfterDisk.filter((name) => !(name in icons))
if (stillMissing.length < 1) {
return { icons, notFound: [] }
}
if (!allowUpstream) {
return { icons, notFound: stillMissing }
}
const fetched = await this.fetchIconsUpstream(prefix, stillMissing)
return {
icons: { ...icons, ...fetched.icons },
notFound: fetched.notFound
}
}
/**
* Fetch icons from upstream and store them permanently.
*
* Refuses for a set that is not enabled, so that a disabled set cannot grow, and holds to the
* upstream budget so that requests for icons that do not exist cannot be amplified.
*/
async fetchIconsUpstream(prefix: string, names: string[]): Promise<ResolvedIcons> {
const set = await this.getSet(prefix)
if (!set?.isEnabled) {
return { icons: {}, notFound: names }
}
// -> A name upstream has already denied is not worth asking about again
const asking = names.filter((name) => !this.isKnownMissing(prefix, name))
if (asking.length < 1) {
return { icons: {}, notFound: names }
}
if (!this.claimUpstreamBudget()) {
WIKI.logger.warn(
`Upstream icon request budget exhausted, not fetching ${prefix}:${asking.join(',')} [ SKIPPED ]`
)
return { icons: {}, notFound: names }
}
let iconSet: IconifyJSON
try {
iconSet = (await this.apiFetch(
`/${prefix}.json?icons=${asking.map(encodeURIComponent).join(',')}`
)) as IconifyJSON
} catch (err: any) {
WIKI.logger.warn(`Could not fetch icons from ${this.apiUrl} [ FAILED ]`)
WIKI.logger.warn(err.message)
return { icons: {}, notFound: names }
}
const icons: Record<string, IconifyIcon> = {}
const notFound: string[] = []
for (const name of asking) {
// -> Resolves aliases, character references and set-level defaults into one self-contained icon
const data = getIconData(iconSet, name)
if (!data?.body) {
notFound.push(name)
this.notFoundCache.set(`${prefix}:${name}`, Date.now())
continue
}
if (!isSafeIconBody(data.body)) {
notFound.push(name)
WIKI.logger.warn(`Refused unsafe icon body for ${prefix}:${name} [ FAILED ]`)
continue
}
icons[name] = data
await this.storeIcon(prefix, name, data)
await this.writeDiskCache(prefix, name, data)
this.remember(prefix, name, data)
}
if (Object.keys(icons).length > 0) {
WIKI.logger.debug(`Stored ${Object.keys(icons).length} new icons for set ${prefix} [ OK ]`)
}
return { icons, notFound: [...notFound, ...names.filter((n) => !asking.includes(n))] }
}
/**
* Write an icon to the permanent record
*/
async storeIcon(prefix: string, name: string, icon: IconifyIcon): Promise<void> {
const values = {
prefix,
name,
body: icon.body,
width: icon.width ?? 16,
height: icon.height ?? 16,
left: icon.left ?? 0,
top: icon.top ?? 0,
rotate: icon.rotate ?? 0,
hFlip: icon.hFlip ?? false,
vFlip: icon.vFlip ?? false
}
await WIKI.db
.insert(iconsTable)
.values(values)
.onConflictDoUpdate({
target: [iconsTable.prefix, iconsTable.name],
set: values
})
}
/**
* Materialize icons so the wiki can serve them without the upstream API.
*
* Called when an icon is picked, i.e. while the author is online and before anyone else needs it.
*
* @param refs References shaped `prefix:name`
* @returns The references that could not be stored
*/
async materializeIcons(refs: string[]): Promise<string[]> {
const byPrefix = new Map<string, string[]>()
const invalid: string[] = []
for (const ref of refs) {
const parsed = this.parseRef(ref)
if (!parsed) {
invalid.push(ref)
continue
}
byPrefix.set(parsed.prefix, [...(byPrefix.get(parsed.prefix) ?? []), parsed.name])
}
const failed = [...invalid]
for (const [prefix, names] of byPrefix) {
const result = await this.resolveIcons(prefix, names)
failed.push(...result.notFound.map((name) => `${prefix}:${name}`))
}
return failed
}
/**
* The SVG for one icon, for the callers that can only carry a URL an `<img>`, a CSS background.
*
* @returns The SVG markup, or null when there is no such icon
*/
async getIconSvg(
prefix: string,
name: string,
{ allowUpstream = true }: { allowUpstream?: boolean } = {}
): Promise<string | null> {
const resolved = await this.resolveIcons(prefix, [name], { allowUpstream })
const icon = resolved.icons[name]
return icon ? this.renderSvg(icon) : null
}
/**
* Turn resolved icon data into standalone SVG markup.
*
* Sized in pixels rather than the `1em` Iconify defaults to, since this file is also used as a plain
* image an `<img>` has no font size to scale against.
*/
renderSvg(icon: IconifyIcon): string {
const rendered = iconToSVG(icon, {
width: `${icon.width ?? 16}`,
height: `${icon.height ?? 16}`
})
return iconToHTML(rendered.body, rendered.attributes)
}
// == CACHE ==========================
/**
* Hold an icon in memory, evicting the least recently stored one when full
*/
remember(prefix: string, name: string, icon: IconifyIcon): void {
if (this.memoryCache.size >= MEMORY_CACHE_MAX) {
const oldest = this.memoryCache.keys().next().value
if (oldest) {
this.memoryCache.delete(oldest)
}
}
this.memoryCache.set(`${prefix}:${name}`, icon)
}
/**
* Whether upstream said recently that it has no such icon
*/
isKnownMissing(prefix: string, name: string): boolean {
const at = this.notFoundCache.get(`${prefix}:${name}`)
if (at === undefined) {
return false
}
if (Date.now() - at > NOT_FOUND_TTL_MS) {
this.notFoundCache.delete(`${prefix}:${name}`)
return false
}
return true
}
/**
* Take one slot from the per-minute upstream allowance
*
* @returns Whether the request may go ahead
*/
claimUpstreamBudget(): boolean {
const now = Date.now()
if (now - this.upstreamBudget.windowStartedAt > 60_000) {
this.upstreamBudget = { windowStartedAt: now, used: 0 }
}
if (this.upstreamBudget.used >= UPSTREAM_BUDGET_PER_MINUTE) {
return false
}
this.upstreamBudget.used++
return true
}
/**
* Where an icon sits in the disk cache.
*
* Icon data rather than rendered SVG, so that one cached file answers both the batch data requests
* the frontend makes and the SVG requests an `<img>` makes rendering from data is string building.
*/
diskCachePath(prefix: string, name: string): string {
return path.join(this.cachePath, prefix, `${name}.json`)
}
/**
* Read an icon from the disk cache
*
* @returns The icon, or null when it is not cached or the file is unusable
*/
async readDiskCache(prefix: string, name: string): Promise<IconifyIcon | null> {
try {
const icon = JSON.parse(await fs.readFile(this.diskCachePath(prefix, name), 'utf8'))
return typeof icon?.body === 'string' ? icon : null
} catch {
// -> Not cached on this instance yet, which is the normal state of a fresh container. A corrupt
// file lands here too and is treated the same way: refill it from the database.
return null
}
}
/**
* Write an icon to the disk cache, best effort.
*
* A full or read-only disk must not stop an icon from being served, hence the swallowed error: the
* cache is derived data and every request can be answered without it.
*
* The file is written under a temporary name and renamed, so that a concurrent reader either sees
* the previous file or the complete new one, never a half-written one.
*/
async writeDiskCache(prefix: string, name: string, icon: IconifyIcon): Promise<void> {
const filePath = this.diskCachePath(prefix, name)
const tempPath = `${filePath}.${process.pid}.tmp`
try {
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(tempPath, JSON.stringify(icon), 'utf8')
await fs.rename(tempPath, filePath)
} catch (err: any) {
WIKI.logger.warn(`Could not write ${filePath} to the icon cache [ SKIPPED ]`)
WIKI.logger.warn(err.message)
await fs.rm(tempPath, { force: true }).catch(() => {})
}
}
/**
* Drop the disk and memory caches. Nothing is lost: both are rebuilt from the database on demand.
*/
async purgeCache(): Promise<void> {
this.memoryCache.clear()
this.notFoundCache.clear()
this.catalogCache.clear()
await fs.rm(this.cachePath, { recursive: true, force: true })
await fs.mkdir(this.cachePath, { recursive: true })
WIKI.logger.info('Purged the icon cache [ OK ]')
}
/**
* What the wiki holds and what it has cached, for the admin area
*/
async getStats(): Promise<{
setCount: number
enabledSetCount: number
iconCount: number
memoryCount: number
diskCount: number
diskSize: number
}> {
const [sets, iconCount] = await Promise.all([
WIKI.db.select({ isEnabled: iconSetsTable.isEnabled }).from(iconSetsTable),
WIKI.db.$count(iconsTable)
])
const disk = await this.measureDiskCache()
return {
setCount: sets.length,
enabledSetCount: sets.filter((s) => s.isEnabled).length,
iconCount,
memoryCount: this.memoryCache.size,
diskCount: disk.files,
diskSize: disk.bytes
}
}
/**
* Walk the disk cache. Cheap enough to do on demand: it holds one small file per icon in use.
*/
async measureDiskCache(): Promise<{ files: number; bytes: number }> {
let files = 0
let bytes = 0
try {
const entries = await fs.readdir(this.cachePath, { recursive: true, withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) {
continue
}
files++
bytes += (await fs.stat(path.join(entry.parentPath, entry.name))).size
}
} catch {
// -> No cache directory yet, which is simply an empty cache
}
return { files, bytes }
}
// == PLUMBING =======================
/**
* Call the upstream Iconify API
*
* @throws When offline mode is on, the request fails, or the response is not JSON
*/
async apiFetch(pathname: string): Promise<any> {
if (WIKI.config.offline) {
return Promise.reject(
new Error('Wiki.js is in offline mode and cannot reach the Iconify API.')
)
}
const url = `${this.apiUrl}${pathname}`
WIKI.logger.debug(`Fetching ${url}`)
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(15_000)
})
if (!resp.ok) {
return Promise.reject(new Error(`${this.apiUrl} answered ${resp.status} for ${pathname}`))
}
const data = await resp.json()
// -> The API answers an unknown prefix with the string `404` and a 200 status
if (typeof data !== 'object' || data === null) {
return Promise.reject(new Error(`${this.apiUrl} has nothing for ${pathname}`))
}
return data
}
rowToIcon(row: typeof iconsTable.$inferSelect): IconifyIcon {
return {
body: row.body,
width: row.width,
height: row.height,
left: row.left,
top: row.top,
rotate: row.rotate,
hFlip: row.hFlip,
vFlip: row.vFlip
}
}
/**
* Make sure the cache directory exists, so that the first icon request is not the one to find out
*/
async ensureCacheDir(): Promise<void> {
try {
await fs.mkdir(this.cachePath, { recursive: true })
} catch (err: any) {
WIKI.logger.warn(`Could not create the icon cache directory ${this.cachePath} [ SKIPPED ]`)
WIKI.logger.warn(err.message)
}
}
/**
* Seed the icon sets a fresh instance starts with.
*
* Deliberately network-free: the wiki has to install without outbound access, so only the prefix and
* a name go in, and the metadata is filled in by the first refresh.
*/
async init(): Promise<void> {
WIKI.logger.info('Inserting default icon sets...')
await WIKI.db
.insert(iconSetsTable)
.values(DEFAULT_SETS.map((set) => ({ ...set, isEnabled: true })))
}
}
export const icons = new Icons()

@ -5,6 +5,7 @@ import { extensions } from './extensions.ts'
import { flags } from './flags.ts'
import { groups } from './groups.ts'
import { hooks } from './hooks.ts'
import { icons } from './icons.ts'
import { jobs } from './jobs.ts'
import { locales } from './locales.ts'
import { search } from './search.ts'
@ -12,6 +13,7 @@ import { security } from './security.ts'
import { sessions } from './sessions.ts'
import { settings } from './settings.ts'
import { sites } from './sites.ts'
import { storage } from './storage.ts'
import { users } from './users.ts'
export default {
@ -22,6 +24,7 @@ export default {
flags,
groups,
hooks,
icons,
jobs,
locales,
search,
@ -29,5 +32,6 @@ export default {
sessions,
settings,
sites,
storage,
users
}

@ -1,6 +1,10 @@
import { mergeWith, toMerged } from 'es-toolkit/object'
import { keyBy } from 'es-toolkit/array'
import { blocks as blocksTable, sites as sitesTable } from '../db/schema.ts'
import {
blocks as blocksTable,
sites as sitesTable,
storage as storageTable
} from '../db/schema.ts'
import { eq } from 'drizzle-orm'
import type { SystemIds } from './types.ts'
@ -183,31 +187,15 @@ class Sites {
// items: []
// })
// WIKI.logger.debug(`Creating new DB storage for site ${newSite.id}`)
// await WIKI.db.storage.query().insert({
// module: 'db',
// siteId: newSite.id,
// isEnabled: true,
// contentTypes: {
// activeTypes: ['pages', 'images', 'documents', 'others', 'large'],
// largeThreshold: '5MB'
// },
// assetDelivery: {
// streaming: true,
// directAccess: false
// },
// state: {
// current: 'ok'
// }
// })
// -> Site lookups by id / hostname are served from cache, which must know about the new site
await WIKI.models.sites.reloadCache()
// -> Otherwise the new site would have no blocks until the next restart
await WIKI.models.blocks.syncSite(newSite.id)
// -> Same for storage: the site needs its database target from the moment it can hold content
await WIKI.models.storage.syncSite(newSite.id)
return newSite
}
@ -253,12 +241,11 @@ class Sites {
}
async deleteSite(id: string): Promise<boolean> {
// await WIKI.db.storage.query().delete().where('siteId', id)
// -> Block rows are registration metadata derived from disk, and their FK has no cascade, so
// they would otherwise block the delete. Content tables (pages, assets, ...) deliberately
// still do — see the conflict handling in the route.
// -> Block and storage rows are registration metadata derived from disk, and their FK has no
// cascade, so they would otherwise block the delete. Content tables (pages, assets, ...)
// deliberately still do — see the conflict handling in the route.
await WIKI.db.delete(blocksTable).where(eq(blocksTable.siteId, id))
await WIKI.db.delete(storageTable).where(eq(storageTable.siteId, id))
const deletedResult = await WIKI.db.delete(sitesTable).where(eq(sitesTable.id, id))
if ((deletedResult.rowCount ?? 0) < 1) {

@ -0,0 +1,605 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import yaml from 'js-yaml'
import { and, eq, inArray } from 'drizzle-orm'
import { parseModuleProps } from '../helpers/common.ts'
import { sites as sitesTable, storage as storageTable } from '../db/schema.ts'
import type { ModuleProp } from '../helpers/common.ts'
/** The kinds of content a target can be asked to hold. */
export const CONTENT_TYPES = ['pages', 'images', 'documents', 'others', 'large'] as const
/**
* The module every site stores its content in, and the only one that is guaranteed to work: assets
* and pages live in the wiki database. It cannot be disabled, as that would leave content nowhere.
*/
const DB_MODULE = 'db'
/** An action a module knows how to run on demand, as declared by its `definition.yml`. */
export interface StorageAction {
/** Key of the handler on the module implementation, i.e. what gets called. */
handler: string
label: string
hint: string
/** Shown in red, and turned into a confirmation prompt by the admin area. */
warn?: string
icon: string
}
/** A storage module, as declared by its `definition.yml`. */
export interface StorageDefinition {
key: string
title: string
description: string
icon: string
banner: string
vendor: string
website: string
contentTypes: {
defaultTypesEnabled: string[]
defaultLargeThreshold: string
}
assetDelivery: {
isStreamingSupported: boolean
isDirectAccessSupported: boolean
defaultStreamingEnabled: boolean
defaultDirectAccessEnabled: boolean
}
versioning: {
isSupported: boolean
/** Versioning is inherent to the module and cannot be turned off, as in a git history. */
isForceEnabled: boolean
defaultEnabled: boolean
}
/** Declared by modules that cannot be configured by hand, e.g. an app installed on a provider. */
setup?: {
handler: string
defaultValues: Record<string, any>
}
props: Record<string, ModuleProp>
actions: StorageAction[]
/**
* Whether a `storage.ts` sits next to the definition.
*
* No module ships one yet, so every target is configuration-only for now: nothing reads or writes
* content through a module. Actions and setup are gated on this, so that the admin area never
* offers to run something that has no implementation behind it.
*/
hasImplementation: boolean
}
/** A configured target: the module definition, plus how this site has it set up. */
export interface StorageTarget {
id: string
module: string
isEnabled: boolean
title: string
description: string
icon: string
banner: string
vendor: string
website: string
contentTypes: {
activeTypes: string[]
largeThreshold: string
}
assetDelivery: {
isStreamingSupported: boolean
isDirectAccessSupported: boolean
streaming: boolean
directAccess: boolean
}
versioning: {
isSupported: boolean
isForceEnabled: boolean
enabled: boolean
}
setup?: {
handler: string
state: string
values: Record<string, any>
}
props: Record<string, ModuleProp>
config: Record<string, any>
actions: StorageAction[]
}
/** The shape a target is written with. Every field is optional, i.e. it doubles as a patch. */
export interface StorageTargetInput {
id: string
isEnabled?: boolean
contentTypes?: {
activeTypes?: string[]
largeThreshold?: string
}
assetDelivery?: {
streaming?: boolean
directAccess?: boolean
}
versioning?: {
enabled?: boolean
}
config?: Record<string, any>
}
/** What a module implementation is expected to export, once any of them do. */
export interface StorageModule {
/** Advance a multi-step setup process, returning what the admin area should do next. */
setup?: (targetId: string, state: Record<string, any>) => Promise<Record<string, any>>
/** Undo whatever `setup` configured, so that it can be started over. */
setupDestroy?: (targetId: string) => Promise<void>
/** Handlers named by the definition's actions. */
[handler: string]: any
}
/**
* Storage model
*
* A storage target is one module configured for one site S3 for assets, git for pages, and so on.
* Each module lives in `modules/storage/<key>/definition.yml`, which declares what it supports and
* what it needs configured. Every site gets a row per module (see `syncSite`), so a target always
* has a stable ID whether or not it has ever been enabled.
*
* Nothing dispatches content to targets yet: pages and assets are read and written straight from the
* database, and no module ships an implementation. What this model handles is the configuration those
* modules will read once they exist.
*/
class Storage {
/** Definitions read from disk, refreshed by `refreshFromDisk()`. */
definitions: StorageDefinition[] = []
/** Implementations loaded by `ensureModule()`, keyed by module. */
modules: Record<string, StorageModule> = {}
/**
* Load the storage module definitions from disk.
*/
async refreshFromDisk(): Promise<void> {
const storagePath = path.join(WIKI.SERVERPATH, 'modules/storage')
const definitions: StorageDefinition[] = []
try {
for (const dir of await fs.readdir(storagePath)) {
const raw = await fs.readFile(path.join(storagePath, dir, 'definition.yml'), 'utf8')
const parsed = yaml.load(raw) as Record<string, any>
// -> The directory name is the key, as it is for every other module type
parsed.key = dir
// -> Props carry a display `order`, applied once here so that every consumer — the admin
// area included — reads them in the order the module meant them to be shown in
parsed.props = Object.fromEntries(
Object.entries(parseModuleProps(parsed.props ?? {})).sort(
([, a], [, b]) => a.order - b.order
)
)
// -> Declared as a map keyed by handler, which is far more readable in YAML than a list of
// objects, but the handler has to travel with the action for it to be callable
parsed.actions = Object.entries(parsed.actions ?? {}).map(([handler, action]) => ({
handler,
...(action as Omit<StorageAction, 'handler'>)
}))
parsed.versioning = {
isSupported: false,
isForceEnabled: false,
defaultEnabled: false,
...parsed.versioning
}
parsed.hasImplementation = await this.hasImplementation(dir)
definitions.push(parsed as StorageDefinition)
}
// -> The database target first, then alphabetically: it is the one every site starts with
this.definitions = definitions.sort((a, b) =>
a.key === DB_MODULE ? -1 : b.key === DB_MODULE ? 1 : a.title.localeCompare(b.title)
)
WIKI.logger.info(`Found ${this.definitions.length} storage modules [ OK ]`)
} catch (err: any) {
this.definitions = []
WIKI.logger.error(
`Could not read the storage module definitions at ${storagePath} [ FAILED ]`
)
WIKI.logger.error(err.message)
}
}
/**
* Whether the module has any code to run, as opposed to only a definition
*/
async hasImplementation(key: string): Promise<boolean> {
try {
await fs.access(path.join(WIKI.SERVERPATH, 'modules/storage', key, 'storage.ts'))
return true
} catch {
return false
}
}
/**
* A single definition, or null when nothing on disk declares that key
*/
getDefinition(key: string): StorageDefinition | null {
return this.definitions.find((d) => d.key === key) ?? null
}
/**
* Give a site a row per installed module, and drop rows for modules no longer on disk.
*
* Existing rows are left alone: their settings belong to the site, whereas everything the
* definition declares is read from disk on every request rather than copied into the row.
*/
async syncSite(siteId: string): Promise<void> {
const existing = await WIKI.db
.select({ module: storageTable.module })
.from(storageTable)
.where(eq(storageTable.siteId, siteId))
const existingKeys = existing.map((t) => t.module)
const definedKeys = this.definitions.map((d) => d.key)
for (const definition of this.definitions) {
if (existingKeys.includes(definition.key)) {
continue
}
await WIKI.db.insert(storageTable).values({
siteId,
module: definition.key,
// -> Content has to land somewhere from the moment a site exists
isEnabled: definition.key === DB_MODULE,
contentTypes: {
activeTypes: definition.contentTypes?.defaultTypesEnabled ?? [],
largeThreshold: definition.contentTypes?.defaultLargeThreshold ?? '5MB'
},
assetDelivery: {
streaming: definition.assetDelivery?.defaultStreamingEnabled ?? false,
directAccess: definition.assetDelivery?.defaultDirectAccessEnabled ?? false
},
versioning: {
enabled: definition.versioning.isForceEnabled || definition.versioning.defaultEnabled
},
config: this.buildConfig(definition.key),
state: definition.setup ? { setup: 'notconfigured' } : {}
})
}
// -> A module removed from disk should not linger in the admin list
const orphaned = existingKeys.filter((key) => !definedKeys.includes(key))
if (orphaned.length > 0) {
await WIKI.db
.delete(storageTable)
.where(and(eq(storageTable.siteId, siteId), inArray(storageTable.module, orphaned)))
}
}
/**
* Register the installed storage modules for every site. Called at boot, after the sites cache.
*/
async syncAllSites(): Promise<void> {
WIKI.logger.info('Registering storage targets for all sites...')
const sites = await WIKI.db.select({ id: sitesTable.id }).from(sitesTable)
for (const site of sites) {
await WIKI.models.storage.syncSite(site.id)
}
WIKI.logger.info(`Registered storage targets for ${sites.length} sites [ OK ]`)
}
/**
* The stored target rows, without anything merged in from disk
*/
async getTargets({
siteId,
enabledOnly = false
}: { siteId?: string; enabledOnly?: boolean } = {}) {
const conditions = [
siteId ? eq(storageTable.siteId, siteId) : undefined,
enabledOnly ? eq(storageTable.isEnabled, true) : undefined
].filter(Boolean)
return WIKI.db
.select()
.from(storageTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
}
/**
* Every target of a site, in the order the admin area lists them.
*
* Config values are completed from the module's declared defaults, so a prop added to a module
* after a target was configured is returned with its default rather than as a missing key.
*/
async getSiteTargets(siteId: string): Promise<StorageTarget[]> {
const rows = await this.getTargets({ siteId })
const targets: StorageTarget[] = []
// -> Driven by the definitions rather than by the rows, so that the list is ordered the same way
// and a module dropped on disk without a restart is simply absent instead of half-present
for (const definition of this.definitions) {
const row = rows.find((t) => t.module === definition.key)
if (!row) {
continue
}
const contentTypes = (row.contentTypes ?? {}) as Record<string, any>
const assetDelivery = (row.assetDelivery ?? {}) as Record<string, any>
const versioning = (row.versioning ?? {}) as Record<string, any>
targets.push({
id: row.id,
module: definition.key,
isEnabled: row.isEnabled,
title: definition.title,
description: definition.description,
icon: definition.icon,
banner: definition.banner,
vendor: definition.vendor,
website: definition.website,
contentTypes: {
activeTypes: contentTypes.activeTypes ?? [],
largeThreshold: contentTypes.largeThreshold ?? '5MB'
},
assetDelivery: {
isStreamingSupported: definition.assetDelivery?.isStreamingSupported ?? false,
isDirectAccessSupported: definition.assetDelivery?.isDirectAccessSupported ?? false,
streaming: assetDelivery.streaming ?? false,
directAccess: assetDelivery.directAccess ?? false
},
versioning: {
isSupported: definition.versioning.isSupported,
isForceEnabled: definition.versioning.isForceEnabled,
enabled: versioning.enabled ?? false
},
// -> Only offered for a module that can actually run its setup process
...(definition.setup &&
definition.hasImplementation && {
setup: {
handler: definition.setup.handler,
state: ((row.state ?? {}) as Record<string, any>).setup ?? 'notconfigured',
values: this.buildSetupValues(definition, row.config as Record<string, any>)
}
}),
props: definition.props,
config: this.buildConfig(definition.key, {}, row.config as Record<string, any>),
// -> Same reasoning as setup: an action with nothing behind it cannot be run
actions: definition.hasImplementation ? definition.actions : []
})
}
return targets
}
/**
* A single target of a site, or null if there is no such target
*/
async getSiteTargetById(siteId: string, id: string): Promise<StorageTarget | null> {
return (await this.getSiteTargets(siteId)).find((t) => t.id === id) ?? null
}
/**
* The values the setup form starts from: whatever the module stored, else its declared defaults.
*/
buildSetupValues(
definition: StorageDefinition,
stored: Record<string, any> = {}
): Record<string, any> {
const values: Record<string, any> = {}
for (const [key, value] of Object.entries(definition.setup?.defaultValues ?? {})) {
values[key] = stored[key] ?? value
}
return values
}
/**
* Merge incoming config values onto the ones already stored, keeping only what the module declares.
*
* Read-only props are never taken from the client: they are declarations of something the server
* does not support changing, so the stored value (or the module default) always wins.
*/
buildConfig(
moduleKey: string,
incoming: Record<string, any> = {},
existing: Record<string, any> = {}
): Record<string, any> {
const props = this.getDefinition(moduleKey)?.props ?? {}
const config: Record<string, any> = {}
for (const [key, prop] of Object.entries(props)) {
const current = existing[key] !== undefined ? existing[key] : prop.default
config[key] = prop.readOnly || incoming[key] === undefined ? current : incoming[key]
}
return config
}
/**
* Check incoming config values against what the module declares.
*
* The props are a runtime declaration read from a YAML file, so no JSON Schema can cover them
* without this, a boolean prop would happily store the string `"maybe"`.
*
* @returns The reason it is invalid, or null when it is fine
*/
validateConfig(moduleKey: string, incoming: Record<string, any> = {}): string | null {
const props = this.getDefinition(moduleKey)?.props ?? {}
for (const [key, value] of Object.entries(incoming)) {
const prop = props[key]
// -> Unknown keys are dropped by buildConfig rather than refused: a module losing a prop must
// not make the admin area unable to save
if (!prop || prop.readOnly || value === undefined) {
continue
}
if (prop.enum) {
// -> Enum entries are declared as `value` or `value|label`
const allowed = prop.enum.map((entry) => entry.split('|')[0])
if (!allowed.includes(`${value}`)) {
return `"${value}" is not a valid value for ${prop.title}.`
}
continue
}
switch (prop.type) {
case 'boolean':
if (typeof value !== 'boolean') {
return `${prop.title} must be true or false.`
}
break
case 'number':
if (typeof value !== 'number' || !Number.isFinite(value)) {
return `${prop.title} must be a number.`
}
break
default:
if (typeof value !== 'string') {
return `${prop.title} must be a string.`
}
}
}
return null
}
/**
* Check a target patch against what its module supports.
*
* @returns The reason it is invalid, or null when it is fine
*/
validateTarget(target: StorageTarget, patch: StorageTargetInput): string | null {
const definition = this.getDefinition(target.module)!
if (patch.isEnabled === false && target.module === DB_MODULE) {
return 'The database storage target cannot be disabled, as content would have nowhere to live.'
}
if (patch.isEnabled === true && target.setup && target.setup.state !== 'configured') {
return `${definition.title} cannot be enabled until its setup process is completed.`
}
const activeTypes = patch.contentTypes?.activeTypes
if (activeTypes) {
const unknown = activeTypes.find(
(type) => !(CONTENT_TYPES as readonly string[]).includes(type)
)
if (unknown) {
return `"${unknown}" is not a valid content type.`
}
if (target.module === DB_MODULE && !activeTypes.includes('pages')) {
return 'The database storage target must keep holding pages.'
}
}
const largeThreshold = patch.contentTypes?.largeThreshold
if (largeThreshold !== undefined && !/^\d+(\.\d+)?\s?(B|KB|MB|GB|TB)$/i.test(largeThreshold)) {
return `"${largeThreshold}" is not a valid size threshold. Use a size such as "5MB".`
}
return this.validateConfig(target.module, patch.config)
}
/**
* Apply a patch to a target.
*
* Capabilities the module does not have are stored as off whatever was asked for, and versioning it
* forces on is stored as on the admin area disables those controls, but the values are the
* module's to decide, not the client's.
*
* @param target The target as it currently stands, which the caller already has from validating
* @returns Whether the target was written
*/
async updateTarget(
siteId: string,
target: StorageTarget,
patch: StorageTargetInput
): Promise<boolean> {
const definition = this.getDefinition(target.module)!
const values: Partial<typeof storageTable.$inferInsert> = {}
if (patch.isEnabled !== undefined) {
values.isEnabled = patch.isEnabled
}
if (patch.contentTypes) {
values.contentTypes = {
activeTypes: patch.contentTypes.activeTypes ?? target.contentTypes.activeTypes,
largeThreshold: patch.contentTypes.largeThreshold ?? target.contentTypes.largeThreshold
}
}
if (patch.assetDelivery) {
values.assetDelivery = {
streaming:
definition.assetDelivery.isStreamingSupported &&
(patch.assetDelivery.streaming ?? target.assetDelivery.streaming),
directAccess:
definition.assetDelivery.isDirectAccessSupported &&
(patch.assetDelivery.directAccess ?? target.assetDelivery.directAccess)
}
}
if (patch.versioning) {
values.versioning = {
enabled:
definition.versioning.isForceEnabled ||
(definition.versioning.isSupported &&
(patch.versioning.enabled ?? target.versioning.enabled))
}
}
if (patch.config !== undefined) {
values.config = this.buildConfig(target.module, patch.config, target.config)
}
if (Object.keys(values).length < 1) {
return false
}
const result = await WIKI.db
.update(storageTable)
.set(values)
.where(and(eq(storageTable.siteId, siteId), eq(storageTable.id, target.id)))
return (result.rowCount ?? 0) > 0
}
/**
* Ensure a module's implementation is loaded
*
* @returns The implementation, or null when the module has none or it failed to load
*/
async ensureModule(key: string): Promise<StorageModule | null> {
if (this.modules[key]) {
return this.modules[key]
}
if (!this.getDefinition(key)?.hasImplementation) {
return null
}
try {
// -> Extension-sensitive dynamic import, invisible to the type checker
this.modules[key] = (await import(`../modules/storage/${key}/storage.ts`)).default
WIKI.logger.debug(`Activated storage module ${key} [ OK ]`)
return this.modules[key]
} catch (err: any) {
WIKI.logger.warn(`Failed to load storage module ${key} [ FAILED ]`)
WIKI.logger.warn(err)
return null
}
}
/**
* Run one of the actions a module declares.
*
* @throws When the module cannot be loaded or does not implement the handler
*/
async executeAction(target: StorageTarget, handler: string): Promise<void> {
const mod = await this.ensureModule(target.module)
if (!mod) {
throw new Error(`The ${target.title} storage module has no implementation installed.`)
}
if (typeof mod[handler] !== 'function') {
throw new Error(`The ${target.title} storage module does not implement "${handler}".`)
}
await mod[handler](target)
}
/**
* Advance a module's setup process.
*
* @returns What the admin area should do next, as decided by the module
* @throws When the module cannot be loaded or has no setup process
*/
async runSetup(target: StorageTarget, state: Record<string, any>): Promise<Record<string, any>> {
const mod = await this.ensureModule(target.module)
if (!mod?.setup) {
throw new Error(`The ${target.title} storage module has no setup process.`)
}
return mod.setup(target.id, state)
}
/**
* Undo a module's setup, so that it can be started over.
*
* @throws When the module cannot be loaded or has no setup process
*/
async destroySetup(target: StorageTarget): Promise<void> {
const mod = await this.ensureModule(target.module)
if (!mod?.setupDestroy) {
throw new Error(`The ${target.title} storage module has no setup process.`)
}
await mod.setupDestroy(target.id)
}
}
export const storage = new Storage()

@ -0,0 +1,56 @@
key: azure
title: Azure Blob Storage
icon: '/_assets/icons/ultraviolet-azure.svg'
banner: '/_assets/storage/azure.jpg'
description: Azure Blob Storage is Microsoft's object storage solution for the cloud. Blob storage is optimized for storing massive amounts of unstructured data.
vendor: Microsoft Corporation
website: 'https://azure.microsoft.com'
assetDelivery:
isStreamingSupported: true
isDirectAccessSupported: true
defaultStreamingEnabled: true
defaultDirectAccessEnabled: true
contentTypes:
defaultTypesEnabled: ['images', 'documents', 'others', 'large']
defaultLargeThreshold: '5MB'
versioning:
isSupported: false
defaultEnabled: false
props:
accountName:
type: String
title: Account Name
default: ''
hint: Your unique account name.
icon: 3d-touch
order: 1
accountKey:
type: String
title: Account Access Key
default: ''
hint: Either key 1 or key 2.
icon: key
sensitive: true
order: 2
containerName:
type: String
title: Container Name
default: wiki
hint: Will automatically be created if it doesn't exist yet.
icon: shipping-container
order: 3
storageTier:
type: String
title: Storage Tier
hint: Represents the access tier on a blob. Use Cool for lower storage costs but at higher retrieval costs.
icon: scan-stock
order: 4
default: cool
enum:
- hot|Hot
- cool|Cool
actions:
exportAll:
label: Export All DB Assets to Azure
hint: Output all content from the DB to Azure Blog Storage, overwriting any existing data. If you enabled Azure Blog Storage after content was created or you temporarily disabled it, you'll want to execute this action to add the missing content.
icon: this-way-up

@ -0,0 +1,25 @@
key: db
title: 'Database'
icon: '/_assets/icons/ultraviolet-database.svg'
banner: '/_assets/storage/database.jpg'
description: 'The local PostgreSQL database can store any assets. It is however not recommended to store large files directly in the database as this can cause performance issues.'
vendor: 'Wiki.js'
website: 'https://js.wiki'
assetDelivery:
isStreamingSupported: true
isDirectAccessSupported: false
defaultStreamingEnabled: true
defaultDirectAccessEnabled: false
contentTypes:
defaultTypesEnabled: ['pages', 'images', 'documents', 'others', 'large']
defaultLargeThreshold: '5MB'
versioning:
isSupported: true
defaultEnabled: false
props: {}
actions:
purge:
label: Purge All Assets
hint: Delete all asset data from the database (not the metadata). Useful if you moved assets to another storage target and want to reduce the size of the database.
warn: This is a destructive action! Make sure all asset files are properly stored on another storage module! This action cannot be undone!
icon: explosion

@ -0,0 +1,45 @@
key: disk
title: Local File System
icon: '/_assets/icons/ultraviolet-hdd.svg'
banner: '/_assets/storage/disk.jpg'
description: Store files on the local file system or over network attached storage. Note that you must use replicated storage if using high-availability instances.
vendor: Wiki.js
website: 'https://js.wiki'
assetDelivery:
isStreamingSupported: true
isDirectAccessSupported: false
defaultStreamingEnabled: true
defaultDirectAccessEnabled: false
contentTypes:
defaultTypesEnabled: ['images', 'documents', 'others', 'large']
defaultLargeThreshold: '5MB'
versioning:
isSupported: false
defaultEnabled: false
props:
path:
type: String
title: Path
hint: Absolute path without a trailing slash (e.g. /home/wiki/backup, C:\wiki\backup)
icon: symlink-directory
order: 1
createDailyBackups:
type: Boolean
default: false
title: Create Daily Backups
hint: A tar.gz archive containing all content will be created daily in subfolder named _daily. Archives are kept for a month.
icon: archive-folder
order: 2
actions:
dump:
label: Dump all content to disk
hint: Output all content from the DB to the local disk. If you enabled this module after content was created or you temporarily disabled this module, you'll want to execute this action to add the missing files.
icon: downloads
backup:
label: Create Backup
hint: Will create a manual backup archive at this point in time, in a subfolder named _manual, from the contents currently on disk.
icon: archive-folder
importAll:
label: Import Everything
hint: Will import all content currently in the local disk folder.
icon: database-daily-import

@ -0,0 +1,65 @@
key: gcs
title: Google Cloud Storage
icon: '/_assets/icons/ultraviolet-google.svg'
banner: '/_assets/storage/gcs.jpg'
description: Google Cloud Storage is an online file storage web service for storing and accessing data on Google Cloud Platform infrastructure.
vendor: Alphabet Inc.
website: 'https://cloud.google.com'
assetDelivery:
isStreamingSupported: true
isDirectAccessSupported: true
defaultStreamingEnabled: true
defaultDirectAccessEnabled: true
contentTypes:
defaultTypesEnabled: ['images', 'documents', 'others', 'large']
defaultLargeThreshold: '5MB'
versioning:
isSupported: false
defaultEnabled: false
props:
accountName:
type: String
title: Project ID
hint: The project ID from the Google Developer's Console (e.g. grape-spaceship-123).
icon: 3d-touch
default: ''
order: 1
credentialsJSON:
type: String
title: JSON Credentials
hint: Contents of the JSON credentials file for the service account having Cloud Storage permissions.
icon: key
default: ''
multiline: true
sensitive: true
order: 2
bucket:
type: String
title: Unique bucket name
hint: The unique bucket name to create (e.g. wiki-johndoe).
icon: open-box
order: 3
storageTier:
type: String
title: Storage Tier
hint: Select the storage class to use when uploading new assets.
icon: scan-stock
order: 4
default: STANDARD
enum:
- STANDARD|Standard
- NEARLINE|Nearline
- COLDLINE|Coldline
- ARCHIVE|Archive
apiEndpoint:
type: String
title: API Endpoint
hint: The API endpoint of the service used to make requests.
icon: api
default: storage.google.com
order: 5
actions:
exportAll:
label: Export All DB Assets to GCS
hint: Output all content from the DB to Google Cloud Storage, overwriting any existing data. If you enabled Google Cloud Storage after content was created or you temporarily disabled it, you'll want to execute this action to add the missing content.
icon: this-way-up

@ -0,0 +1,148 @@
key: git
title: Local Git
icon: '/_assets/icons/ultraviolet-git.svg'
banner: '/_assets/storage/git.jpg'
description: Git is a version control system for tracking changes in computer files and coordinating work on those files among multiple people. If using GitHub, use the GitHub module instead!
vendor: Software Freedom Conservancy, Inc.
website: 'https://git-scm.com'
assetDelivery:
isStreamingSupported: true
isDirectAccessSupported: false
defaultStreamingEnabled: true
defaultDirectAccessEnabled: false
contentTypes:
defaultTypesEnabled: ['pages', 'images', 'documents', 'others', 'large']
defaultLargeThreshold: '5MB'
versioning:
isSupported: true
defaultEnabled: true
isForceEnabled: true
# Synchronization (direction and schedule) is not modelled yet — nothing reads a sync declaration, so
# this module currently only holds configuration.
props:
authType:
type: String
default: 'ssh'
title: Authentication Type
hint: Use SSH for maximum security.
icon: security-configuration
enum:
- basic|Basic
- ssh|SSH
enumDisplay: buttons
order: 1
repoUrl:
type: String
title: Repository URI
hint: Git-compliant URI (e.g. git@server.com:org/repo.git for ssh, https://server.com/org/repo.git for basic)
icon: dns
order: 2
branch:
type: String
default: 'main'
title: Branch
hint: The branch to use during pull / push
icon: code-fork
order: 3
sshPrivateKeyMode:
type: String
title: SSH Private Key Mode
hint: The mode to use to load the private key. Fill in the corresponding field below.
icon: grand-master-key
order: 11
default: inline
enum:
- path|File Path
- inline|Inline Contents
enumDisplay: buttons
if:
- { key: 'authType', eq: 'ssh' }
sshPrivateKeyPath:
type: String
title: SSH Private Key Path
hint: Absolute path to the key. The key must NOT be passphrase-protected.
icon: key
order: 12
if:
- { key: 'authType', eq: 'ssh' }
- { key: 'sshPrivateKeyMode', eq: 'path' }
sshPrivateKeyContent:
type: String
title: SSH Private Key Contents
hint: Paste the contents of the private key. The key must NOT be passphrase-protected.
icon: key
multiline: true
sensitive: true
order: 13
if:
- { key: 'authType', eq: 'ssh' }
- { key: 'sshPrivateKeyMode', eq: 'inline' }
verifySSL:
type: Boolean
default: true
title: Verify SSL Certificate
hint: Some hosts requires SSL certificate checking to be disabled. Leave enabled for proper security.
icon: security-ssl
order: 14
basicUsername:
type: String
title: Username
hint: Basic Authentication Only
icon: test-account
order: 20
if:
- { key: 'authType', eq: 'basic' }
basicPassword:
type: String
title: Password / PAT
hint: Basic Authentication Only
icon: password
sensitive: true
order: 21
if:
- { key: 'authType', eq: 'basic' }
defaultEmail:
type: String
title: Default Author Email
default: 'name@company.com'
hint: 'Used as fallback in case the author of the change is not present.'
icon: email
order: 30
defaultName:
type: String
title: Default Author Name
default: 'John Smith'
hint: 'Used as fallback in case the author of the change is not present.'
icon: customer
order: 31
localRepoPath:
type: String
title: Local Repository Path
default: './data/repo'
hint: 'Path where the local git repository will be created.'
icon: symlink-directory
order: 32
gitBinaryPath:
type: String
title: Git Binary Path
default: ''
hint: Optional - Absolute path to the Git binary, when not available in PATH. Leave empty to use the default PATH location (recommended).
icon: run-command
order: 50
actions:
syncUntracked:
label: Add Untracked Changes
hint: Output all content from the DB to the local Git repository to ensure all untracked content is saved. If you enabled Git after content was created or you temporarily disabled Git, you'll want to execute this action to add the missing untracked changes.
icon: database-daily-export
sync:
label: Force Sync
hint: Will trigger an immediate sync operation, regardless of the current sync schedule. The sync direction is respected.
icon: synchronize
importAll:
label: Import Everything
hint: Will import all content currently in the local Git repository, regardless of the latest commit state. Useful for importing content from the remote repository created before git was enabled.
icon: database-daily-import
purge:
label: Purge Local Repository
hint: If you have unrelated merge histories, clearing the local repository can resolve this issue. This will not affect the remote repository or perform any commit.
icon: trash

@ -0,0 +1,159 @@
key: s3
title: AWS S3 / Cloudflare R2 / DO Spaces
icon: '/_assets/icons/ultraviolet-amazon-web-services.svg'
banner: '/_assets/storage/s3.jpg'
description: Amazon Simple Storage Service (Amazon S3) is an object storage service offering industry-leading scalability, data availability, security, and performance.
vendor: Amazon.com, Inc.
website: 'https://aws.amazon.com'
assetDelivery:
isStreamingSupported: true
isDirectAccessSupported: true
defaultStreamingEnabled: true
defaultDirectAccessEnabled: true
contentTypes:
defaultTypesEnabled: ['images', 'documents', 'others', 'large']
defaultLargeThreshold: '5MB'
versioning:
isSupported: false
defaultEnabled: false
props:
mode:
type: String
title: Mode
hint: Select a preset configuration mode or define a custom one.
icon: tune
default: aws
order: 1
enum:
- aws|AWS S3
- do|DigitalOcean Spaces
- custom|Custom
awsRegion:
type: String
title: Region
hint: The AWS datacenter region where the bucket will be created.
icon: geography
default: us-east-1
enum:
- af-south-1|af-south-1 - Africa (Cape Town)
- ap-east-1|ap-east-1 - Asia Pacific (Hong Kong)
- ap-southeast-3|ap-southeast-3 - Asia Pacific (Jakarta)
- ap-south-1|ap-south-1 - Asia Pacific (Mumbai)
- ap-northeast-3|ap-northeast-3 - Asia Pacific (Osaka)
- ap-northeast-2|ap-northeast-2 - Asia Pacific (Seoul)
- ap-southeast-1|ap-southeast-1 - Asia Pacific (Singapore)
- ap-southeast-2|ap-southeast-2 - Asia Pacific (Sydney)
- ap-northeast-1|ap-northeast-1 - Asia Pacific (Tokyo)
- ca-central-1|ca-central-1 - Canada (Central)
- cn-north-1|cn-north-1 - China (Beijing)
- cn-northwest-1|cn-northwest-1 - China (Ningxia)
- eu-central-1|eu-central-1 - Europe (Frankfurt)
- eu-west-1|eu-west-1 - Europe (Ireland)
- eu-west-2|eu-west-2 - Europe (London)
- eu-south-1|eu-south-1 - Europe (Milan)
- eu-west-3|eu-west-3 - Europe (Paris)
- eu-north-1|eu-north-1 - Europe (Stockholm)
- me-south-1|me-south-1 - Middle East (Bahrain)
- sa-east-1|sa-east-1 - South America (São Paulo)
- us-east-1|us-east-1 - US East (N. Virginia)
- us-east-2|us-east-2 - US East (Ohio)
- us-west-1|us-west-1 - US West (N. California)
- us-west-2|us-west-2 - US West (Oregon)
order: 2
if:
- { key: 'mode', eq: 'aws' }
doRegion:
type: String
title: Region
hint: The DigitalOcean Spaces region
icon: geography
default: nyc3
enum:
- ams3|Amsterdam
- fra1|Frankfurt
- nyc3|New York
- sfo2|San Francisco 2
- sfo3|San Francisco 3
- sgp1|Singapore
order: 2
if:
- { key: 'mode', eq: 'do' }
endpoint:
type: String
title: Endpoint URI
hint: The full S3-compliant endpoint URI.
icon: dns
default: https://service.region.example.com
order: 2
if:
- { key: 'mode', eq: 'custom' }
bucket:
type: String
title: Unique bucket name
hint: The unique bucket name to create (e.g. wiki-johndoe).
icon: open-box
order: 3
accessKeyId:
type: String
title: Access Key ID
hint: The Access Key.
icon: 3d-touch
order: 4
secretAccessKey:
type: String
title: Secret Access Key
hint: The Secret Access Key for the Access Key ID you created above.
icon: key
sensitive: true
order: 5
storageTier:
type: String
title: Storage Tier
hint: The storage tier to use when adding files.
icon: scan-stock
order: 6
default: STANDARD
enum:
- STANDARD|Standard
- STANDARD_IA|Standard Infrequent Access
- INTELLIGENT_TIERING|Intelligent Tiering
- ONEZONE_IA|One Zone Infrequent Access
- REDUCED_REDUNDANCY|Reduced Redundancy
- GLACIER_IR|Glacier Instant Retrieval
- GLACIER|Glacier Flexible Retrieval
- DEEP_ARCHIVE|Glacier Deep Archive
- OUTPOSTS|Outposts
if:
- { key: 'mode', eq: 'aws' }
sslEnabled:
type: Boolean
title: Use SSL
hint: Whether to enable SSL for requests
icon: secure
default: true
order: 10
if:
- { key: 'mode', eq: 'custom' }
s3ForcePathStyle:
type: Boolean
title: Force Path Style for S3 objects
hint: Whether to force path style URLs for S3 objects.
icon: filtration
default: false
order: 11
if:
- { key: 'mode', eq: 'custom' }
s3BucketEndpoint:
type: Boolean
title: Single Bucket Endpoint
hint: Whether the provided endpoint addresses an individual bucket.
icon: swipe-right
default: false
order: 12
if:
- { key: 'mode', eq: 'custom' }
actions:
exportAll:
label: Export All DB Assets to S3
hint: Output all content from the DB to S3, overwriting any existing data. If you enabled S3 after content was created or you temporarily disabled it, you'll want to execute this action to add the missing content.
icon: this-way-up

@ -0,0 +1,94 @@
key: sftp
title: 'SFTP'
icon: '/_assets/icons/ultraviolet-nas.svg'
banner: '/_assets/storage/ssh.jpg'
description: 'Store files over a remote connection using the SSH File Transfer Protocol.'
vendor: 'Wiki.js'
website: 'https://js.wiki'
assetDelivery:
isStreamingSupported: false
isDirectAccessSupported: false
defaultStreamingEnabled: false
defaultDirectAccessEnabled: false
contentTypes:
defaultTypesEnabled: ['pages', 'images', 'documents', 'others', 'large']
defaultLargeThreshold: '5MB'
versioning:
isSupported: false
defaultEnabled: false
props:
host:
type: String
title: Host
default: ''
hint: Hostname or IP of the remote SSH server.
icon: dns
order: 1
port:
type: Number
title: Port
default: 22
hint: SSH port of the remote server.
icon: ethernet-off
order: 2
authMode:
type: String
title: Authentication Method
default: 'privateKey'
hint: Whether to use Private Key or Password-based authentication. A private key is highly recommended for best security.
icon: grand-master-key
enum:
- privateKey|Private Key
- password|Password
enumDisplay: buttons
order: 3
username:
type: String
title: Username
default: ''
hint: Username for authentication.
icon: test-account
order: 4
privateKey:
type: String
title: Private Key Contents
default: ''
hint: Contents of the private key
icon: key
multiline: true
sensitive: true
order: 5
if:
- { key: 'authMode', eq: 'privateKey' }
passphrase:
type: String
title: Private Key Passphrase
default: ''
hint: Passphrase if the private key is encrypted, leave empty otherwise
icon: password
sensitive: true
order: 6
if:
- { key: 'authMode', eq: 'privateKey' }
password:
type: String
title: Password
default: ''
hint: Password for authentication
icon: password
sensitive: true
order: 6
if:
- { key: 'authMode', eq: 'password' }
basePath:
type: String
title: Base Directory Path
default: '/root/wiki'
hint: Base directory where files will be transferred to. The path must already exists and be writable by the user.
icon: symlink-directory
actions:
exportAll:
label: Export All DB Assets to Remote
hint: Output all content from the DB to the remote SSH server, overwriting any existing data. If you enabled SFTP after content was created or you temporarily disabled it, you'll want to execute this action to add the missing content.
icon: this-way-up

@ -22,6 +22,7 @@
"@fastify/swagger-ui": "6.0.0",
"@fastify/view": "12.0.0",
"@gquittet/graceful-server": "6.0.10",
"@iconify/utils": "3.1.4",
"ajv-formats": "3.0.1",
"bcryptjs": "3.0.3",
"chalk": "5.6.2",
@ -63,6 +64,19 @@
"node": ">=26.0"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
"license": "MIT",
"dependencies": {
"package-manager-detector": "^1.3.0",
"tinyexec": "^1.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@azure-rest/core-client": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.5.1.tgz",
@ -1309,6 +1323,23 @@
"fsevents": "^2.3.3"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz",
"integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==",
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.1.0",
"@iconify/types": "^2.0.0",
"import-meta-resolve": "^4.2.0"
}
},
"node_modules/@js-joda/core": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz",
@ -3826,6 +3857,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/import-meta-resolve": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
"integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -4676,6 +4717,12 @@
}
}
},
"node_modules/package-manager-detector": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz",
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
"license": "MIT"
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@ -5462,6 +5509,15 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinypool": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz",

@ -48,6 +48,7 @@
"@fastify/swagger-ui": "6.0.0",
"@fastify/view": "12.0.0",
"@gquittet/graceful-server": "6.0.10",
"@iconify/utils": "3.1.4",
"ajv-formats": "3.0.1",
"bcryptjs": "3.0.3",
"chalk": "5.6.2",

@ -40,10 +40,6 @@ declare global {
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

@ -73,6 +73,16 @@ offline: false
# Writeable data path used for cache and temporary user uploads.
dataPath: ./data
# ---------------------------------------------------------------------
# Icons
# ---------------------------------------------------------------------
# Icons are fetched from the Iconify API the first time they are used, then
# stored in the database and served by this instance. Point this at a
# self-hosted Iconify API to keep icon lookups inside your network.
icons:
apiUrl: 'https://api.iconify.design'
# ---------------------------------------------------------------------
# Body Parser Limit
# ---------------------------------------------------------------------

@ -24,6 +24,7 @@
"filesize-parser": "1.5.1",
"fuse.js": "7.4.2",
"highlight.js": "11.11.1",
"iconify-icon": "3.0.2",
"js-cookie": "3.0.8",
"jwt-decode": "4.0.0",
"katex": "0.17.0",
@ -1312,6 +1313,12 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@inquirer/ansi": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
@ -7524,6 +7531,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/iconify-icon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/iconify-icon/-/iconify-icon-3.0.2.tgz",
"integrity": "sha512-DYPAumiUeUeT/GHT8x2wrAVKn1FqZJqFH0Y5pBefapWRreV1BBvqBVMb0020YQ2njmbR59r/IathL2d2OrDrxA==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",

@ -29,6 +29,7 @@
"filesize-parser": "1.5.1",
"fuse.js": "7.4.2",
"highlight.js": "11.11.1",
"iconify-icon": "3.0.2",
"js-cookie": "3.0.8",
"jwt-decode": "4.0.0",
"katex": "0.17.0",

@ -1,11 +1,13 @@
import BlueprintIcon from '@/components/BlueprintIcon.vue'
import StatusLight from '@/components/StatusLight.vue'
import LoadingGeneric from '@/components/LoadingGeneric.vue'
import WikiIcon from '@/components/WikiIcon.vue'
import VNetworkGraph from 'v-network-graph'
export function initializeComponents (app) {
app.component('BlueprintIcon', BlueprintIcon)
app.component('LoadingGeneric', LoadingGeneric)
app.component('StatusLight', StatusLight)
app.component('WikiIcon', WikiIcon)
app.use(VNetworkGraph)
}

@ -0,0 +1,18 @@
import { addAPIProvider } from 'iconify-icon'
/**
* Point Iconify at this wiki instead of the public Iconify API.
*
* The `iconify-icon` element resolves `<prefix>:<name>` by asking an API for the icon data, batching
* every icon a page needs into one request per set and caching the answers in localStorage. Replacing
* the default provider means that traffic goes to `/_icons` on this instance: icons are served from
* the wiki's own store, nothing about which pages a reader visits leaks to a third party, and the wiki
* keeps working when it has no outbound access at all.
*
* Importing the package for its side effect is what defines the `<iconify-icon>` custom element.
*/
export function initializeIconify () {
addAPIProvider('', {
resources: [`${window.location.origin}/_icons`]
})
}

@ -1,6 +1,5 @@
<!-- eslint-disable -->
<template lang="pug">
q-card.icon-picker(flat, style='width: 400px')
q-card.icon-picker(flat, style='width: 460px')
q-tabs.text-primary(
v-model='state.currentTab'
no-caps
@ -9,127 +8,125 @@ q-card.icon-picker(flat, style='width: 400px')
q-tab(
name='icon'
icon='las la-icons'
label='Icon'
:label='t(`iconPicker.icons`)'
)
q-tab(
name='img'
icon='las la-image'
label='Image'
name='custom'
icon='las la-pen'
:label='t(`iconPicker.custom`)'
)
q-separator
q-tab-panels(
v-model='state.currentTab'
)
q-tab-panel(name='icon')
q-select(
:options='iconPacks'
v-model='state.selPack'
emit-value
map-options
outlined
dense
transition-show='jump-down'
transition-hide='jump-up'
)
template(v-slot:option='scope')
q-item(
v-bind='scope.itemProps'
v-on='scope.itemEvents'
:class='scope.selected ? `bg-primary text-white` : ``'
q-tab-panels(v-model='state.currentTab')
//- -----------------------
//- Iconify search
//- -----------------------
q-tab-panel.q-pa-sm(name='icon')
.row.q-col-gutter-sm
.col
q-input(
v-model='state.query'
outlined
dense
clearable
autofocus
:label='t(`iconPicker.search`)'
:aria-label='t(`iconPicker.search`)'
@update:model-value='queueSearch'
)
template(#prepend)
q-icon(name='las la-search')
.col-auto
q-select(
v-model='state.setFilter'
:options='setOptions'
outlined
dense
options-dense
emit-value
map-options
style='min-width: 130px;'
:label='t(`iconPicker.set`)'
:aria-label='t(`iconPicker.set`)'
@update:model-value='search'
)
.icon-picker-results.q-mt-sm
q-inner-loading(:showing='state.loading')
q-spinner-tail(color='primary', size='md')
.text-center.text-caption.text-grey.q-pa-lg(v-if='!state.loading && state.results.length < 1')
| {{ state.query?.length >= 2 ? t('iconPicker.noResults') : t('iconPicker.searchHint') }}
.icon-picker-grid(v-else)
q-btn.icon-picker-cell(
v-for='icon of state.results'
:key='icon'
flat
dense
:class='{ "icon-picker-cell--active": state.selected === icon }'
:aria-label='icon'
@click='state.selected = icon'
)
q-item-section(side)
q-icon(
name='las la-box'
:color='scope.selected ? `white` : `grey`'
)
q-item-section
q-item-label {{scope.opt.name}}
q-item-label(caption)
strong(:class='scope.selected ? `text-white` : `text-primary`') {{scope.opt.subset}}
q-item-section(side, v-if='scope.opt.subset')
q-chip(
color='primary'
text-color='white'
rounded
size='sm'
) {{scope.opt.subset.toUpperCase()}}
q-input.q-mt-md(
v-model='state.selIcon'
wiki-icon(:name='icon', size='24px')
q-tooltip {{ icon }}
//- -----------------------
//- Anything else
//- -----------------------
q-tab-panel(name='custom')
.text-caption.text-grey {{ t('iconPicker.customHint') }}
q-input.q-mt-sm(
v-model='state.custom'
outlined
label='Icon Name'
dense
:label='t(`iconPicker.reference`)'
:aria-label='t(`iconPicker.reference`)'
placeholder='las la-home'
)
.row.q-gutter-md.q-mt-none
.col-auto
q-avatar(
size='64px'
color='primary'
rounded
)
q-icon(
:name='iconName'
color='white'
size='64px'
)
.col
.text-caption Learn how to #[a(href='https://docs.requarks.io') use icons].
.text-caption.q-mt-sm View #[a(:href='iconPackRefWebsite', target='_blank') Icon Pack reference] for all possible options.
q-tab-panel(name='img')
.row.q-gutter-sm
q-btn.col(
label='Browse...'
color='secondary'
icon='las la-file-image'
unelevated
no-caps
)
q-btn.col(
label='Upload...'
color='secondary'
icon='las la-upload'
unelevated
no-caps
)
.q-mt-md.text-center
q-avatar(
size='64px'
rounded
)
q-img(
transition='jump-down'
:ratio='1'
:src='state.imgPath'
)
q-separator
q-card-section.row.items-center.q-py-sm
q-avatar(size='40px', rounded, :color='$q.dark.isActive ? `dark-3` : `grey-2`')
wiki-icon(:name='pendingValue', size='28px', color='primary')
.col.q-pl-sm
.text-caption.text-grey {{ t('iconPicker.selection') }}
.text-body2.icon-picker-ref {{ pendingValue || '—' }}
q-separator
q-card-actions
q-space
q-btn(
icon='las la-times'
label='Discard'
:label='t(`common.actions.discard`)'
outline
color='grey-7'
v-close-popup
)
q-btn(
icon='las la-check'
label='Apply'
:label='t(`common.actions.apply`)'
unelevated
color='secondary'
:disable='!pendingValue'
@click='apply'
v-close-popup
)
</template>
<script setup>
import { find } from 'lodash-es'
import { debounce } from 'es-toolkit/function'
import { useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n'
import { computed, onMounted, reactive } from 'vue'
// QUASAR
const $q = useQuasar()
// I18N
const { t } = useI18n()
// PROPS
const props = defineProps({
modelValue: {
type: String,
required: true
default: ''
}
})
@ -141,58 +138,121 @@ const emit = defineEmits(['update:modelValue'])
const state = reactive({
currentTab: 'icon',
selPack: 'las',
selIcon: '',
imgPath: 'https://placeimg.com/64/64/nature'
query: '',
setFilter: '',
sets: [],
results: [],
selected: '',
custom: '',
loading: false
})
const iconPacks = [
{ value: 'las', label: 'Line Awesome (solid)', name: 'Line Awesome', subset: 'solid', prefix: 'las la-', reference: 'https://icons8.com/line-awesome' },
{ value: 'lab', label: 'Line Awesome (brands)', name: 'Line Awesome', subset: 'brands', prefix: 'lab la-', reference: 'https://icons8.com/line-awesome' },
{ value: 'mdi', label: 'Material Design Icons', name: 'Material Design Icons', prefix: 'mdi-', reference: 'https://materialdesignicons.com' },
{ value: 'fas', label: 'Font Awesome (solid)', name: 'Font Awesome', subset: 'solid', prefix: 'fas fa-', reference: 'https://fontawesome.com/icons' },
{ value: 'far', label: 'Font Awesome (regular)', name: 'Font Awesome', subset: 'regular', prefix: 'far fa-', reference: 'https://fontawesome.com/icons' },
{ value: 'fal', label: 'Font Awesome (light)', name: 'Font Awesome', subset: 'light', prefix: 'fal fa-', reference: 'https://fontawesome.com/icons' },
{ value: 'fad', label: 'Font Awesome (duotone)', name: 'Font Awesome', subset: 'duotone', prefix: 'fad fa-', reference: 'https://fontawesome.com/icons' },
{ value: 'fab', label: 'Font Awesome (brands)', name: 'Font Awesome', subset: 'brands', prefix: 'fab fa-', reference: 'https://fontawesome.com/icons' }
]
/** An Iconify reference, as opposed to a webfont name or an `img:` URL. */
const ICONIFY_REF = /^[a-z0-9-]+:[a-z0-9.-]+$/
// COMPUTED
const iconName = computed(() => {
return find(iconPacks, ['value', state.selPack]).prefix + state.selIcon
const setOptions = computed(() => {
return [
{ value: '', label: t('iconPicker.allSets') },
...state.sets.map(set => ({ value: set.prefix, label: set.name }))
]
})
const iconPackRefWebsite = computed(() => {
return find(iconPacks, ['value', state.selPack]).reference
const pendingValue = computed(() => {
return state.currentTab === 'custom' ? (state.custom?.trim() ?? '') : state.selected
})
// METHODS
function apply () {
if (state.currentTab === 'img') {
emit('update:modelValue', `img:${state.imgPath}`)
} else {
emit('update:modelValue', state.iconName)
/**
* Read the API's own message off a failed request, since ky doesn't throw on 400
*/
async function apiMessage (err) {
return err.response?.json().then(b => b?.message).catch(() => null) ?? err.message
}
async function loadSets () {
try {
// -> Only enabled sets: a disabled one is not searchable, and its icons cannot be stored
const sets = await API_CLIENT.get('icons/sets').json()
state.sets = (sets ?? []).filter(set => set.isEnabled)
} catch (err) {
$q.notify({
type: 'negative',
message: t('iconPicker.setsFailed'),
caption: await apiMessage(err)
})
}
}
// MOUNTED
async function search () {
const query = state.query?.trim()
if (!query || query.length < 2) {
state.results = []
return
}
onMounted(() => {
if (props.modelValue?.startsWith('img:')) {
state.currentTab = 'img'
state.imgPath = props.modelValue.substring(4)
} else {
state.currentTab = 'icon'
for (const pack of iconPacks) {
if (props.value?.startsWith(pack.prefix)) {
state.selPack = pack.value
state.selIcon = props.modelValue.substring(pack.prefix.length)
break
}
state.loading = true
try {
const params = new URLSearchParams({ query })
if (state.setFilter) {
params.set('prefixes', state.setFilter)
}
const resp = await API_CLIENT.get(`icons/search?${params}`).json()
state.results = resp?.icons ?? []
} catch (err) {
state.results = []
$q.notify({
type: 'negative',
message: t('iconPicker.searchFailed'),
caption: await apiMessage(err)
})
}
state.loading = false
}
// -> Every keystroke would otherwise be a search against the upstream API
const queueSearch = debounce(search, 350)
/**
* Hand the reference back, having made sure the wiki can serve it.
*
* Drawing the results already stored the ones that were previewed; this covers a reference that was
* typed rather than picked, and makes the guarantee explicit at the moment content starts pointing at
* it from here on the icon is served from the wiki, with or without the Iconify API.
*/
async function apply () {
const value = pendingValue.value
emit('update:modelValue', value)
if (!ICONIFY_REF.test(value)) {
return
}
try {
await API_CLIENT.post('icons/materialize', { json: { icons: [value] } }).json()
} catch (err) {
// -> The reference is saved either way; it just may not render until the icon can be fetched
$q.notify({
type: 'warning',
message: t('iconPicker.materializeFailed', { icon: value }),
caption: await apiMessage(err)
})
}
}
// MOUNTED
onMounted(async () => {
// -> An Iconify reference starts on the search tab, anything else on the custom one
if (ICONIFY_REF.test(props.modelValue ?? '')) {
state.selected = props.modelValue
state.results = [props.modelValue]
} else if (props.modelValue) {
state.currentTab = 'custom'
state.custom = props.modelValue
}
await loadSets()
})
</script>
@ -224,5 +284,44 @@ onMounted(() => {
background-color: $dark-5;
}
}
&-results {
position: relative;
height: 220px;
overflow-y: auto;
border-radius: 4px;
@at-root .body--light & {
background-color: #FFF;
}
@at-root .body--dark & {
background-color: $dark-5;
}
}
&-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
gap: 2px;
padding: 4px;
}
&-cell {
height: 44px;
&--active {
@at-root .body--light & {
background-color: $blue-1;
}
@at-root .body--dark & {
background-color: $blue-9;
}
}
}
&-ref {
font-family: monospace;
word-break: break-all;
}
}
</style>

@ -72,7 +72,7 @@ q-layout(view='hHh lpR fFf', container)
clickable
)
q-item-section(side)
q-icon(:name='element.icon', color='white')
wiki-icon(:name='element.icon', color='white')
q-item-section.text-wordbreak-all {{ element.label }}
q-item-section(side)
q-icon.handle(name='mdi-drag-horizontal', size='sm')
@ -242,8 +242,7 @@ q-layout(view='hHh lpR fFf', container)
color='primary'
)
q-menu(content-class='shadow-7')
.q-pa-lg: em [ TODO: Icon Picker Dialog ]
// icon-picker-dialog(v-model='pageStore.icon')
icon-picker-dialog(v-model='state.current.icon')
q-separator.q-my-sm(inset)
q-item
blueprint-icon(icon='link')

@ -15,10 +15,14 @@ q-scroll-area.sidebar-nav(
) {{ item.label }}
q-expansion-item(
v-else-if='item.type === `link` && item.children?.length > 0'
:icon='item.icon'
:label='item.label'
dense
)
//- The icon goes through a header slot rather than the `icon` prop, so that an Iconify
//- reference is drawn by wiki-icon like everywhere else
template(#header)
q-item-section(side)
wiki-icon(:name='item.icon', color='white')
q-item-section.text-wordbreak-all.text-white {{ item.label }}
q-list(
clickable
dense
@ -30,14 +34,14 @@ q-scroll-area.sidebar-nav(
:key='itemChild.id'
)
q-item-section(side)
q-icon(:name='itemChild.icon', color='white')
wiki-icon(:name='itemChild.icon', color='white')
q-item-section.text-wordbreak-all.text-white {{ itemChild.label }}
q-item(
v-else-if='item.type === `link`'
:to='item.target'
)
q-item-section(side)
q-icon(:name='item.icon', color='white')
wiki-icon(:name='item.icon', color='white')
q-item-section.text-wordbreak-all.text-white {{ item.label }}
q-separator(
v-else-if='item.type === `separator`'

@ -6,16 +6,16 @@
v-if='editorStore.isActive'
padding='none'
size='37px'
:icon='pageStore.icon'
color='primary'
flat
:aria-label='t(`editor.props.icon`)'
)
wiki-icon(:name='pageStore.icon', size='37px')
q-badge(color='grey' floating rounded)
q-icon(name='las la-pen', size='xs', padding='xs xs')
q-menu(content-class='shadow-7')
.q-pa-lg: em [ TODO: Icon Picker Dialog ]
// icon-picker-dialog(v-model='pageStore.icon')
q-icon.rounded-borders(
icon-picker-dialog(v-model='pageStore.icon')
wiki-icon.rounded-borders(
v-else
:name='pageStore.icon'
size='64px'

@ -110,7 +110,7 @@ q-card.page-properties-dialog
)
q-item(v-for='rel of pageStore.relations', :key='`rel-id-` + rel.id')
q-item-section(side)
q-icon(:name='rel.icon')
wiki-icon(:name='rel.icon')
q-item-section
q-item-label: strong {{rel.label}}
q-item-label(caption) {{rel.caption}}

@ -52,32 +52,32 @@ q-card.page-relation-dialog(style='width: 500px;')
v-if='state.pos === `left`'
padding='sm md'
outline
:icon='state.icon'
no-caps
color='primary'
)
wiki-icon(:name='state.icon')
.column.text-left.q-pl-md
.text-body2: strong {{state.label}}
.text-caption {{state.caption}}
q-btn.full-width(
v-else-if='state.pos === `center`'
:label='state.label'
color='primary'
flat
no-caps
:icon='state.icon'
)
)
wiki-icon.q-mr-sm(:name='state.icon')
span {{ state.label }}
q-btn(
v-else-if='state.pos === `right`'
padding='sm md'
outline
:icon-right='state.icon'
no-caps
color='primary'
)
.column.text-left.q-pr-md
.text-body2: strong {{state.label}}
.text-caption {{state.caption}}
wiki-icon(:name='state.icon')
q-card-actions.card-actions
q-space
q-btn.acrylic-btn(

@ -0,0 +1,70 @@
<template lang='pug'>
iconify-icon(
v-if='isIconifyRef'
:icon='props.name'
:class='colorClass'
:style='sizeStyle'
aria-hidden='true'
)
q-icon(
v-else
:name='props.name'
:size='props.size'
:color='props.color'
)
</template>
<script setup>
import { computed } from 'vue'
/**
* An icon reference, either of the two kinds the wiki has to draw:
*
* - `<prefix>:<name>` an Iconify reference, e.g. `mdi:account-edit`. This is what the icon picker
* stores and what everything new should use: the element asks `/_icons` for the icon data and inlines
* the SVG, so the icon takes its color from CSS like a glyph would.
* - anything else handed to `q-icon` untouched, which covers the webfont names used across the admin
* area (`las la-cog`, `mdi-check`) as well as its `img:` and `svguse:` forms.
*/
const props = defineProps({
name: {
type: String,
default: ''
},
size: {
type: String,
default: null
},
/** A Quasar color name, applied as a text color so that it works for either kind. */
color: {
type: String,
default: null
}
})
// -> Deliberately strict, so that `img:/_assets/x.svg` and `svguse:...#id` stay with q-icon
const ICONIFY_REF = /^[a-z0-9]+(?:-[a-z0-9]+)*:[a-z0-9]+(?:[-.][a-z0-9]+)*$/
/** Quasar's named icon sizes, which `q-icon` resolves through its own size table. */
const NAMED_SIZES = {
xs: '18px',
sm: '24px',
md: '32px',
lg: '38px',
xl: '46px'
}
// COMPUTED
const isIconifyRef = computed(() => ICONIFY_REF.test(props.name ?? ''))
const colorClass = computed(() => (props.color ? `text-${props.color}` : undefined))
/**
* `iconify-icon` sizes itself in `em`, so setting the font size scales it the way q-icon does.
*/
const sizeStyle = computed(() => {
if (!props.size) { return undefined }
return { fontSize: NAMED_SIZES[props.size] ?? props.size }
})
</script>

@ -7,6 +7,7 @@ import { initializeComponents } from './boot/components'
import { initializeEventBus } from './boot/eventbus'
import { initializeExternals } from './boot/externals'
import { initializeI18n } from './boot/i18n'
import { initializeIconify } from './boot/iconify'
import { initializeTemporal } from './boot/temporal'
import quasarIconSet from 'quasar/icon-set/mdi-v7'
@ -34,6 +35,7 @@ app.use(router)
initializeApi(store)
initializeComponents(app)
initializeEventBus()
initializeIconify()
initializeExternals(router, store)
initializeI18n(app, store)

@ -28,82 +28,187 @@ q-page.admin-icons
q-tooltip {{ t(`common.actions.refresh`) }}
q-btn(
unelevated
icon='mdi-check'
:label='t(`common.actions.apply`)'
color='secondary'
@click='save'
:disabled='state.loading > 0'
icon='las la-plus'
:label='t(`admin.icons.addSet`)'
color='primary'
@click='openAddSet'
)
q-separator(inset)
.row.q-pa-md.q-col-gutter-md
.col-12
.col-12.col-lg
//- -----------------------
//- Icon Sets
//- -----------------------
q-card
q-card-section
q-card.bg-negative.text-white.rounded-borders(flat)
q-card-section.items-center(horizontal)
q-card-section.col-auto.q-pr-none
q-icon(name='las la-exclamation-triangle', size='sm')
q-card-section
span {{ t('admin.icons.warnLabel') }}
.text-caption.text-red-1 {{ t('admin.icons.warnHint') }}
.text-subtitle1 {{ t('admin.icons.sets') }}
.text-body2.text-grey {{ t('admin.icons.setsHint') }}
q-banner.q-mx-md.q-mb-md(
v-if='state.sets.length < 1 && state.loading < 1'
rounded
:class='$q.dark.isActive ? `bg-grey-9 text-white` : `bg-grey-2 text-grey-7`'
) {{ t('admin.icons.noSets') }}
q-list(separator)
q-item(v-for='pack of combinedPacks', :key='pack.key')
blueprint-icon(icon='small-icons', :hueRotate='30')
q-item-section
q-item-label: strong {{pack.label}}
q-item-label(caption, v-if='pack.isMandatory')
em {{t('admin.icons.mandatory')}}
template(v-if='pack.config')
q-item-section(
side
)
q-btn(
icon='las la-cog'
:label='t(`admin.editors.configuration`)'
:color='$q.dark.isActive ? `blue-grey-3` : `blue-grey-8`'
outline
no-caps
padding='xs md'
q-item(v-for='set of state.sets', :key='set.prefix')
q-item-section(side)
.admin-icons-samples
wiki-icon.admin-icons-sample(
v-for='sample of sampleRefs(set)'
:key='sample'
:name='sample'
size='24px'
)
q-separator.q-ml-md(vertical)
q-item-section(
side
)
q-btn(
q-icon(v-if='sampleRefs(set).length < 1', name='las la-icons', size='24px', color='grey')
q-item-section
q-item-label
strong {{ set.name }}
q-chip.q-ml-sm(square, dense, size='sm', color='primary', text-color='white') {{ set.prefix }}
q-item-label(caption) {{ setCaption(set) }}
q-item-label.text-deep-orange(caption, v-if='set.info?.palette') {{ t('admin.icons.paletteWarn') }}
q-item-section(side)
q-btn.acrylic-btn(
type='a'
icon='las la-external-link-square-alt'
:label='t(`admin.icons.reference`)'
color='indigo'
outline
flat
no-caps
padding='xs md'
:href='pack.website'
:href='referenceUrl(set)'
target='_blank'
rel='noreferrer noopener'
)
)
q-tooltip {{ t('admin.icons.referenceHint') }}
q-separator.q-ml-md(vertical)
q-item-section(side)
q-toggle.q-pr-sm(
:modelValue='pack.isActive'
@update:model-value='newValue => setPackState(pack.key, newValue)'
:color='pack.isDisabled ? `grey` : `primary`'
:modelValue='set.isEnabled'
@update:model-value='newValue => setSetState(set, newValue)'
color='primary'
checked-icon='las la-check'
unchecked-icon='las la-times'
:label='t(`admin.sites.isActive`)'
:aria-label='t(`admin.sites.isActive`)'
:disabled='pack.isMandatory'
:label='t(`admin.icons.isEnabled`)'
:aria-label='t(`admin.icons.isEnabled`)'
)
q-item-section(side)
q-btn.acrylic-btn(
icon='las la-trash'
flat
color='negative'
:aria-label='t(`common.actions.delete`)'
@click='confirmDeleteSet(set)'
)
q-tooltip {{ t(`common.actions.delete`) }}
.col-12.col-lg-auto
//- -----------------------
//- Storage / Cache
//- -----------------------
q-card.rounded-borders(style='width: 350px;')
q-card-section
.text-subtitle1 {{ t('admin.icons.storage') }}
.text-body2.text-grey {{ t('admin.icons.storageHint') }}
q-list.q-pb-sm(dense)
q-item
q-item-section
q-item-label.text-grey {{ t('admin.icons.storedIcons') }}
q-item-label {{ t('admin.icons.storedIconsValue', { count: state.cache.iconCount ?? 0 }) }}
q-separator.q-my-sm(inset)
q-item
q-item-section
q-item-label.text-grey {{ t('admin.icons.diskCache') }}
q-item-label {{ t('admin.icons.diskCacheValue', { count: state.cache.diskCount ?? 0, size: prettyBytes(state.cache.diskSize ?? 0) }) }}
q-separator.q-my-sm(inset)
q-item
q-item-section
q-item-label.text-grey {{ t('admin.icons.memoryCache') }}
q-item-label {{ t('admin.icons.memoryCacheValue', { count: state.cache.memoryCount ?? 0 }) }}
q-separator
q-card-actions.q-px-md
q-btn.acrylic-btn(
flat
no-caps
icon='las la-broom'
color='negative'
:label='t(`admin.icons.purgeCache`)'
@click='purgeCache'
)
q-tooltip {{ t('admin.icons.purgeCacheHint') }}
//- -----------------------
//- How it works
//- -----------------------
q-card.rounded-borders.q-mt-md(style='width: 350px;')
q-card-section
.text-subtitle1 {{ t('admin.icons.howItWorks') }}
.text-body2.text-grey.q-mt-sm {{ t('admin.icons.howItWorksHint') }}
q-separator.q-mb-sm(inset)
q-item
q-item-section
q-item-label.text-grey {{ t('admin.icons.upstream') }}
q-item-label.text-caption {{ t('admin.icons.upstreamHint') }}
//- -----------------------
//- Add Set Dialog
//- -----------------------
q-dialog(v-model='state.addSetDialog')
q-card(style='width: 700px; max-width: 90vw;')
q-card-section.row.items-center.q-pb-none
.text-h6 {{ t('admin.icons.addSet') }}
q-space
q-btn(icon='las la-times', flat, round, dense, v-close-popup)
q-card-section
.text-body2.text-grey {{ t('admin.icons.addSetHint') }}
q-input.q-mt-md(
v-model='state.availableFilter'
outlined
dense
clearable
:label='t(`admin.icons.filterSets`)'
:aria-label='t(`admin.icons.filterSets`)'
)
template(#prepend)
q-icon(name='las la-search')
q-separator
q-card-section.q-pa-none(style='height: 50vh; overflow-y: auto;')
q-inner-loading(:showing='state.loadingAvailable')
q-spinner-tail(color='primary', size='md')
q-banner.q-ma-md(
v-if='state.availableError'
rounded
class='bg-negative text-white'
) {{ state.availableError }}
q-list(separator)
q-item(
v-for='set of filteredAvailableSets'
:key='set.prefix'
clickable
:disable='set.isAdded'
@click='addSet(set)'
)
q-item-section(side)
.admin-icons-samples
wiki-icon.admin-icons-sample(
v-for='sample of set.samples.slice(0, 3)'
:key='sample'
:name='`${set.prefix}:${sample}`'
size='24px'
)
q-item-section
q-item-label
strong {{ set.name }}
q-chip.q-ml-sm(square, dense, size='sm', color='primary', text-color='white') {{ set.prefix }}
q-item-label(caption) {{ availableCaption(set) }}
q-item-section(side)
q-chip(v-if='set.isAdded', dense, size='sm', color='positive', text-color='white', icon='las la-check') {{ t('admin.icons.added') }}
q-icon(v-else, name='las la-plus-circle', color='primary', size='sm')
</template>
<script setup>
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch } from 'vue'
import { computed, onMounted, reactive } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
// QUASAR
@ -112,7 +217,6 @@ const $q = useQuasar()
// STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore()
// I18N
@ -128,104 +232,269 @@ useMeta({
// DATA
const state = reactive({
loading: false,
config: {
la: { isActive: true }
}
loading: 0,
sets: [],
cache: {},
addSetDialog: false,
availableSets: [],
availableFilter: '',
availableError: '',
loadingAvailable: false
})
const packs = [
{
key: 'bs',
label: 'Bootstrap Icons',
website: 'https://icons.getbootstrap.com'
},
{
key: 'eva',
label: 'Eva Icons',
website: 'https://akveo.github.io/eva-icons'
},
{
key: 'fa',
label: 'Font Awesome',
website: 'https://fontawesome.com',
config: {}
},
{
key: 'io',
label: 'Ionicons',
website: 'https://ionic.io/ionicons'
},
{
key: 'la',
label: 'Line Awesome',
isMandatory: true,
website: 'https://icons8.com/line-awesome'
},
{
key: 'mdi',
label: 'Material Design Icons',
website: 'https://materialdesignicons.com',
isMandatory: true
},
{
key: 'thm',
label: 'Themify Icons',
website: 'https://themify.me/themify-icons'
}
]
// COMPUTED
const combinedPacks = computed(() => {
return packs.map(p => ({
...p,
isActive: (state.config?.[p.key]?.isActive || p.isMandatory) ?? false
}))
const filteredAvailableSets = computed(() => {
const filter = state.availableFilter?.trim().toLowerCase()
if (!filter) { return state.availableSets }
return state.availableSets.filter(set => {
return set.name.toLowerCase().includes(filter) ||
set.prefix.includes(filter) ||
set.category.toLowerCase().includes(filter)
})
})
// METHODS
/**
* Read the API's own message off a failed request, since ky doesn't throw on 400
*/
async function apiMessage (err) {
return err.response?.json().then(b => b?.message).catch(() => null) ?? err.message
}
function prettyBytes (bytes) {
if (bytes < 1024) { return `${bytes} B` }
if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} kB` }
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
/**
* A few icons of the set to show next to it, from the samples upstream publishes
*/
function sampleRefs (set) {
return (set.info?.samples ?? []).slice(0, 3).map(sample => `${set.prefix}:${sample}`)
}
/**
* The Iconify page for the set, which lists every icon it holds with its name.
*
* Deliberately not the author's own site: what an administrator needs from here is the names to
* search for, and the Iconify browser is the catalog those names come from.
*/
function referenceUrl (set) {
return `https://icon-sets.iconify.design/${set.prefix}/`
}
function setCaption (set) {
const parts = [t('admin.icons.setIconCount', { count: set.iconCount })]
if (set.info?.total) {
parts.push(t('admin.icons.setTotal', { total: set.info.total.toLocaleString() }))
}
if (set.info?.license?.title) {
parts.push(set.info.license.title)
}
return parts.join(' • ')
}
function availableCaption (set) {
return [
t('admin.icons.setTotal', { total: set.total.toLocaleString() }),
set.category,
set.license
].filter(Boolean).join(' • ')
}
async function load () {
// state.loading++
// $q.loading.show()
// const resp = await APOLLO_CLIENT.query({
// query: `
// query fetchExtensions {
// systemExtensions {
// key
// title
// description
// isInstalled
// isInstallable
// isCompatible
// }
// }
// `,
// fetchPolicy: 'network-only'
// })
// state.extensions = cloneDeep(resp?.data?.systemExtensions)
// $q.loading.hide()
// state.loading--
state.loading++
try {
const [sets, cache] = await Promise.all([
API_CLIENT.get('icons/sets').json(),
API_CLIENT.get('icons/cache').json()
])
state.sets = sets ?? []
state.cache = cache ?? {}
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.icons.loadFailed'),
caption: await apiMessage(err)
})
}
state.loading--
}
/**
* Bring the set metadata up to date with upstream, then reload.
*
* Sets seeded at install time have no metadata until this runs, since installing must not depend on
* outbound access.
*/
async function refreshSets () {
state.loading++
try {
await API_CLIENT.post('icons/sets/refresh').json()
} catch {
// -> Metadata is a nicety; a wiki with no outbound access still serves every icon it holds
}
state.loading--
await load()
}
async function save () {
async function openAddSet () {
state.addSetDialog = true
if (state.availableSets.length > 0) { return }
state.loadingAvailable = true
state.availableError = ''
try {
state.availableSets = await API_CLIENT.get('icons/available-sets').json() ?? []
} catch (err) {
state.availableError = await apiMessage(err)
}
state.loadingAvailable = false
}
function setPackState (packKey, newValue) {
state.config[packKey] = {
...state.config[packKey] ?? {},
isActive: newValue
async function addSet (set) {
if (set.isAdded) { return }
state.loading++
try {
const resp = await API_CLIENT.post('icons/sets', { json: { prefix: set.prefix } }).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
set.isAdded = true
$q.notify({
type: 'positive',
message: t('admin.icons.addSuccess', { set: set.name })
})
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.icons.addFailed'),
caption: await apiMessage(err)
})
}
state.loading--
await load()
}
async function setSetState (set, isEnabled) {
state.loading++
try {
const resp = await API_CLIENT.put(`icons/sets/${set.prefix}`, { json: { isEnabled } }).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: isEnabled
? t('admin.icons.enableSuccess', { set: set.name })
: t('admin.icons.disableSuccess', { set: set.name })
})
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.icons.saveFailed'),
caption: await apiMessage(err)
})
}
state.loading--
await load()
}
function confirmDeleteSet (set) {
$q.dialog({
title: t('admin.icons.deleteSet'),
message: t('admin.icons.deleteSetConfirm', { set: set.name, count: set.iconCount }),
persistent: true,
ok: {
label: t('common.actions.delete'),
color: 'negative',
unelevated: true
},
cancel: {
label: t('common.actions.cancel'),
color: 'grey',
flat: true
}
}).onOk(async () => {
state.loading++
try {
const resp = await API_CLIENT.delete(`icons/sets/${set.prefix}`).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('admin.icons.deleteSuccess', { set: set.name })
})
// -> The catalog now offers it again
const available = state.availableSets.find(s => s.prefix === set.prefix)
if (available) {
available.isAdded = false
}
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.icons.deleteFailed'),
caption: await apiMessage(err)
})
}
state.loading--
await load()
})
}
function purgeCache () {
$q.dialog({
title: t('admin.icons.purgeCache'),
message: t('admin.icons.purgeCacheConfirm'),
persistent: true,
ok: {
label: t('admin.icons.purgeCache'),
color: 'negative',
unelevated: true
},
cancel: {
label: t('common.actions.cancel'),
color: 'grey',
flat: true
}
}).onOk(async () => {
state.loading++
try {
const resp = await API_CLIENT.delete('icons/cache').json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('admin.icons.purgeCacheSuccess')
})
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.icons.purgeCacheFailed'),
caption: await apiMessage(err)
})
}
state.loading--
await load()
})
}
// MOUNTED
onMounted(() => {
load()
onMounted(async () => {
await load()
// -> A set with no metadata has never been described by upstream: seeded at install, or added while
// the API was unreachable
if (state.sets.some(set => !set.info?.total)) {
await refreshSets()
}
})
</script>
<style lang='scss'>
@ -234,11 +503,17 @@ onMounted(() => {
animation: fadeInLeft .6s forwards, flower-rotate 30s linear infinite;
}
&-packlink {
&-samples {
display: flex;
gap: 4px;
width: 84px;
}
&-sample {
color: $blue-8;
&:hover, &:focus {
color: $blue-4;
body.body--dark & {
color: $blue-3;
}
}
}

@ -41,7 +41,7 @@ q-page.admin-storage
icon='mdi-check'
:label='t(`common.actions.apply`)'
color='secondary'
@click='save'
@click='save()'
:loading='state.loading > 0'
)
q-separator(inset)
@ -60,7 +60,7 @@ q-page.admin-storage
)
q-item(
v-for='tgt of state.targets'
:key='tgt.key'
:key='tgt.id'
active-class='bg-primary text-white'
:active='state.selectedTarget === tgt.id'
:to='`/_admin/` + adminStore.currentSiteId + `/storage/` + tgt.id'
@ -311,7 +311,7 @@ q-page.admin-storage
v-if='configIfCheck(cfg.if)'
)
q-separator.q-my-sm(inset, v-if='idx > 0')
q-item(v-if='cfg.type === `Boolean`', tag='label')
q-item(v-if='cfg.type === `boolean`', tag='label')
blueprint-icon(:icon='cfg.icon', :hue-rotate='cfg.readOnly ? -45 : 0')
q-item-section
q-item-label {{cfg.title}}
@ -331,7 +331,7 @@ q-page.admin-storage
q-item-label {{cfg.title}}
q-item-label(caption) {{cfg.hint}}
q-item-section(
:style='cfg.type === `Number` ? `flex: 0 0 150px;` : ``'
:style='cfg.type === `number` ? `flex: 0 0 150px;` : ``'
:class='{ "col-auto": cfg.enum && cfg.enumDisplay === `buttons` }'
)
q-btn-toggle(
@ -361,7 +361,7 @@ q-page.admin-storage
outlined
v-model='cfg.value'
dense
:type='cfg.multiline ? `textarea` : `input`'
:type='inputTypeFor(cfg)'
:aria-label='cfg.title'
:disable='cfg.readOnly'
)
@ -410,8 +410,10 @@ q-page.admin-storage
flat
icon='las la-arrow-circle-right'
color='primary'
@click=''
@click='executeAction(act)'
:label='t(`common.actions.proceed`)'
:disable='state.runningAction'
:loading='state.runningActionHandler === act.handler'
)
.col-12.col-lg-auto
@ -591,8 +593,6 @@ q-page.admin-storage
</template>
<script setup>
import { cloneDeep, find, transform } from 'lodash-es'
import * as VNG from 'v-network-graph'
import { useI18n } from 'vue-i18n'
@ -716,9 +716,6 @@ const githubSetupForm = ref(null)
const isSetupNeeded = computed(() => {
return state.target?.setup?.handler && state.target.setup.state !== 'configured'
})
const isSetupCompleted = computed(() => {
return state.target?.setup?.handler && state.target.setup.state !== 'configured'
})
// WATCHERS
@ -734,15 +731,18 @@ watch(() => state.displayMode, (newValue) => {
}
})
watch(() => state.selectedTarget, (newValue) => {
state.target = find(state.targets, ['id', newValue]) || null
state.target = state.targets.find(tgt => tgt.id === newValue) || null
})
watch(() => state.targets, (newValue) => {
if (newValue && newValue.length > 0) {
if (state.desiredTarget) {
state.selectedTarget = state.desiredTarget
state.desiredTarget = ''
} else if (newValue.some(tgt => tgt.id === state.selectedTarget)) {
// -> Keep the current selection across a reload, since saving reloads the targets
state.target = newValue.find(tgt => tgt.id === state.selectedTarget)
} else {
state.selectedTarget = find(state.targets, ['module', 'db'])?.id || null
state.selectedTarget = newValue.find(tgt => tgt.module === 'db')?.id || null
if (!route.params.id) {
router.replace(`/_admin/${adminStore.currentSiteId}/storage/${state.selectedTarget}`)
}
@ -763,48 +763,54 @@ watch(() => route.params.id, (to, from) => {
// METHODS
/**
* Turn a module prop declaration and its stored value into the shape the config editor renders,
* expanding `value|label` enum entries into options.
*/
function buildConfigEditor (props, values) {
const config = {}
for (const [key, prop] of Object.entries(props ?? {})) {
config[key] = {
...prop,
value: values?.[key] ?? prop.default,
...prop.enum && {
enum: prop.enum.map(entry => {
const [value, label] = entry.split('|')
return { value, label: label ?? value }
})
}
}
}
return config
}
function inputTypeFor (cfg) {
if (cfg.multiline) { return 'textarea' }
if (cfg.sensitive) { return 'password' }
return cfg.type === 'number' ? 'number' : 'text'
}
/**
* Read the API's own message off a failed request, since ky doesn't throw on 400
*/
async function apiMessage (err) {
return err.response?.json().then(b => b?.message).catch(() => null) ?? err.message
}
async function load () {
state.loading++
$q.loading.show()
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getStorageTargets (
$siteId: UUID!
) {
storageTargets (
siteId: $siteId
) {
id
isEnabled
module
title
description
icon
banner
vendor
website
contentTypes
assetDelivery
versioning
sync
status
setup
config
actions
}
}`,
variables: {
siteId: adminStore.currentSiteId
},
fetchPolicy: 'network-only'
})
state.targets = cloneDeep(resp?.data?.storageTargets)
const targets = await API_CLIENT.get(`sites/${adminStore.currentSiteId}/storage/targets`).json()
state.targets = (targets ?? []).map(tgt => ({
...tgt,
config: buildConfigEditor(tgt.props, tgt.config)
}))
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to load storage configuration.',
caption: err.message,
message: t('admin.storage.loadFailed'),
caption: await apiMessage(err),
timeout: 20000
})
}
@ -817,69 +823,68 @@ function configIfCheck (ifs) {
return ifs.every(s => state.target.config[s.key]?.value === s.eq)
}
async function refresh () {
await load()
$q.notify({
type: 'positive',
message: 'List of storage targets has been refreshed.'
})
/**
* A target as the API expects it. Read-only props are left out: the server keeps whatever is stored
* for them, so sending them back would be pretending they can be set.
*/
function payloadFor (tgt) {
const config = {}
for (const [key, cfg] of Object.entries(tgt.config ?? {})) {
if (cfg.readOnly) { continue }
config[key] = cfg.type === 'number' ? Number(cfg.value) : cfg.value
}
return {
id: tgt.id,
isEnabled: tgt.isEnabled,
contentTypes: {
activeTypes: tgt.contentTypes.activeTypes,
largeThreshold: tgt.contentTypes.largeThreshold
},
assetDelivery: {
streaming: tgt.assetDelivery.streaming,
directAccess: tgt.assetDelivery.directAccess
},
versioning: {
enabled: tgt.versioning.enabled
},
config
}
}
async function save ({ silent }) {
/**
* Save every target at once, the way the API takes them a target is only meaningful next to the
* others, e.g. which of them holds a given content type.
*
* @param silent Skip the loading overlay and the success notification, for a save made on the way to
* something else, such as the GitHub setup flow.
*/
async function save ({ silent = false } = {}) {
let saveSuccess = false
state.loading++
if (!silent) { $q.loading.show() }
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation (
$siteId: UUID!
$targets: [StorageTargetInput]!
) {
updateStorageTargets(
siteId: $siteId
targets: $targets
) {
operation {
succeeded
message
}
}
}
`,
variables: {
siteId: adminStore.currentSiteId,
targets: state.targets.map(tgt => ({
id: tgt.id,
module: tgt.module,
isEnabled: tgt.isEnabled,
contentTypes: tgt.contentTypes.activeTypes,
largeThreshold: tgt.contentTypes.largeThreshold,
assetDeliveryFileStreaming: tgt.assetDelivery.streaming,
assetDeliveryDirectAccess: tgt.assetDelivery.directAccess,
useVersioning: tgt.versioning.enabled,
config: transform(tgt.config, (r, v, k) => { r[k] = v.value }, {})
}))
}
})
if (resp?.data?.updateStorageTargets?.operation?.succeeded) {
saveSuccess = true
if (!silent) {
$q.notify({
type: 'positive',
message: t('admin.storage.saveSuccess')
})
}
} else {
throw new Error(resp?.data?.updateStorageTargets?.operation?.message || 'Unexpected error')
const resp = await API_CLIENT.put(`sites/${adminStore.currentSiteId}/storage/targets`, {
json: { targets: state.targets.map(payloadFor) }
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
saveSuccess = true
if (!silent) {
$q.notify({
type: 'positive',
message: t('admin.storage.saveSuccess')
})
}
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.storage.saveFailed'),
caption: err.message
caption: await apiMessage(err)
})
}
if (!silent) { $q.loading.hide() }
state.loading--
return saveSuccess
}
@ -910,36 +915,58 @@ function getTargetSubtitleColor (target) {
}
}
function getDefaultSchedule (val) {
if (!val) { return 'N/A' }
return '' // moment.duration(val).format('y [years], M [months], d [days], h [hours], m [minutes]')
}
async function executeAction (act) {
const run = async () => {
state.runningAction = true
state.runningActionHandler = act.handler
try {
const resp = await API_CLIENT.post(
`sites/${adminStore.currentSiteId}/storage/targets/${state.selectedTarget}/actions/${act.handler}`
).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('admin.storage.actionSuccess', { action: act.label })
})
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.storage.actionFailed', { action: act.label }),
caption: await apiMessage(err)
})
}
state.runningAction = false
state.runningActionHandler = ''
}
async function executeAction (targetKey, handler) {
// this.$store.commit('loadingStart', 'admin-storage-executeaction')
// this.runningAction = true
// this.runningActionHandler = handler
// try {
// await this.$apollo.mutate({
// mutation: `{}`,
// variables: {
// targetKey,
// handler
// }
// })
// this.$store.commit('showNotification', {
// message: 'Action completed.',
// style: 'success',
// icon: 'check'
// })
// } catch (err) {
// console.warn(err)
// }
// this.runningAction = false
// this.runningActionHandler = ''
// this.$store.commit('loadingStop', 'admin-storage-executeaction')
// -> An action that declares a warning destroys something, so it is never run on a single click
if (act.warn) {
$q.dialog({
title: act.label,
message: act.warn,
persistent: true,
ok: {
label: t('common.actions.proceed'),
color: 'negative',
unelevated: true
},
cancel: {
label: t('common.actions.cancel'),
color: 'grey',
flat: true
}
}).onOk(run)
} else {
await run()
}
}
/**
* Pick up a setup flow that took the administrator to a provider and back, the provider having
* returned them here with a code in the query string.
*/
async function handleSetupCallback () {
if (state.targets.length < 1 || !state.selectedTarget) { return }
@ -962,44 +989,28 @@ async function setupDestroy () {
})
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation (
$targetId: UUID!
) {
destroyStorageTargetSetup(
targetId: $targetId
) {
operation {
succeeded
message
}
}
}
`,
variables: {
targetId: state.selectedTarget
}
})
if (resp?.data?.destroyStorageTargetSetup?.operation?.succeeded) {
state.target.setup.state = 'notconfigured'
setTimeout(() => {
$q.loading.hide()
$q.notify({
type: 'positive',
message: t('admin.storage.githubSetupDestroySuccess')
})
}, 2000)
} else {
throw new Error(resp?.data?.destroyStorageTargetSetup?.operation?.message || 'Unexpected error')
const resp = await API_CLIENT.delete(
`sites/${adminStore.currentSiteId}/storage/targets/${state.selectedTarget}/setup`
).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
state.target.setup.state = 'notconfigured'
// -> GitHub needs a moment to settle before the setup can be started over
setTimeout(() => {
$q.loading.hide()
$q.notify({
type: 'positive',
message: t('admin.storage.githubSetupDestroySuccess')
})
}, 2000)
} catch (err) {
$q.loading.hide()
$q.notify({
type: 'negative',
message: t('admin.storage.githubSetupDestroyFailed'),
caption: err.message
caption: await apiMessage(err)
})
$q.loading.hide()
}
})
}
@ -1061,6 +1072,8 @@ async function setupGitHub () {
$q.loading.show({
message: t('admin.storage.githubPreparingManifest')
})
// -> The values typed into the setup form are stored as config, since GitHub sends the
// administrator back here and the flow has to resume from them
if (await save({ silent: true })) {
githubSetupForm.value.submit()
} else {
@ -1075,80 +1088,63 @@ async function setupGitHubStep (step, code) {
})
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation (
$targetId: UUID!
$state: JSON!
) {
setupStorageTarget(
targetId: $targetId
state: $state
) {
operation {
succeeded
message
}
state
}
}
`,
variables: {
targetId: state.selectedTarget,
state: {
const resp = await API_CLIENT.post(
`sites/${adminStore.currentSiteId}/storage/targets/${state.selectedTarget}/setup`,
{
json: {
step,
...code && { code }
}
}
})
if (resp?.data?.setupStorageTarget?.operation?.succeeded) {
switch (resp.data.setupStorageTarget.state?.nextStep) {
case 'installApp': {
router.replace({ query: null })
).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
switch (resp.state?.nextStep) {
case 'installApp': {
router.replace({ query: null })
$q.loading.hide()
$q.dialog({
component: GithubSetupInstallDialog,
persistent: true
}).onOk(() => {
$q.loading.show({
message: t('admin.storage.githubRedirecting')
})
window.location.assign(resp.state?.url)
}).onCancel(() => {
throw new Error('Setup was aborted prematurely.')
})
break
}
case 'completed': {
state.target.isEnabled = true
state.target.setup.state = 'configured'
setTimeout(() => {
$q.loading.hide()
$q.dialog({
component: GithubSetupInstallDialog,
persistent: true
}).onOk(() => {
$q.loading.show({
message: t('admin.storage.githubRedirecting')
})
window.location.assign(resp.data.setupStorageTarget.state?.url)
}).onCancel(() => {
throw new Error('Setup was aborted prematurely.')
$q.notify({
type: 'positive',
message: t('admin.storage.githubSetupSuccess')
})
break
}
case 'completed': {
state.target.isEnabled = true
state.target.setup.state = 'configured'
setTimeout(() => {
$q.loading.hide()
$q.notify({
type: 'positive',
message: t('admin.storage.githubSetupSuccess')
})
}, 2000)
break
}
default: {
throw new Error('Unknown Setup Step')
}
}, 2000)
break
}
default: {
throw new Error('Unknown Setup Step')
}
} else {
throw new Error(resp?.data?.setupStorageTarget?.operation?.message || 'Unexpected error')
}
} catch (err) {
$q.loading.hide()
$q.notify({
type: 'negative',
message: t('admin.storage.githubSetupFailed'),
caption: err.message
caption: await apiMessage(err)
})
}
}
function generateGraph () {
const types = [
{ key: 'images', label: t('admin.storage.contentTypeImages'), icon: 'las', iconText: '&#xf1c5;' },
@ -1184,7 +1180,7 @@ function generateGraph () {
state.deliveryLayouts.nodes[tp.key] = { x: 0, y: (i + 1) * 15 }
// -> Find target with direct access
const dt = find(state.targets, tgt => {
const dt = state.targets.find(tgt => {
return tgt.module !== 'db' && tgt.contentTypes.activeTypes.includes(tp.key) && tgt.isEnabled && tgt.assetDelivery.isDirectAccessSupported && tgt.assetDelivery.directAccess
})
@ -1201,7 +1197,7 @@ function generateGraph () {
// -> Find target with streaming
const st = find(state.targets, tgt => {
const st = state.targets.find(tgt => {
return tgt.module !== 'db' && tgt.contentTypes.activeTypes.includes(tp.key) && tgt.isEnabled && tgt.assetDelivery.isStreamingSupported && tgt.assetDelivery.streaming
})
@ -1220,8 +1216,8 @@ function generateGraph () {
// -> Check DB fallback
const dbt = find(state.targets, ['module', 'db'])
if (dbt.contentTypes.activeTypes.includes(tp.key)) {
const dbt = state.targets.find(tgt => tgt.module === 'db')
if (dbt?.contentTypes?.activeTypes?.includes(tp.key)) {
state.deliveryNodes[`${tp.key}_wiki`] = { name: 'Wiki.js', icon: '/_assets/logo-wikijs.svg', color: '#161b22' }
state.deliveryLayouts.nodes[`${tp.key}_wiki`] = { x: 60, y: (i + 1) * 15 }
state.deliveryEdges[`${tp.key}_db_in`] = { source: tp.key, target: `${tp.key}_wiki` }

@ -51,31 +51,30 @@ q-page.column
q-btn.q-mr-sm.q-mb-sm(
padding='sm md'
outline
:icon='rel.icon'
no-caps
color='primary'
v-for='rel of relationsLeft'
:key='`rel-id-` + rel.id'
)
wiki-icon(:name='rel.icon')
.column.text-left.q-pl-md
.text-body2: strong {{rel.label}}
.text-caption {{rel.caption}}
.col.text-center(v-if='relationsCenter.length > 0')
.column
q-btn(
:label='rel.label'
color='primary'
flat
no-caps
:icon='rel.icon'
v-for='rel of relationsCenter'
:key='`rel-id-` + rel.id'
)
)
wiki-icon.q-mr-sm(:name='rel.icon')
span {{ rel.label }}
.col.text-right(v-if='relationsRight.length > 0')
q-btn.q-ml-sm.q-mb-sm(
padding='sm md'
outline
:icon-right='rel.icon'
no-caps
color='primary'
v-for='rel of relationsRight'
@ -84,6 +83,7 @@ q-page.column
.column.text-left.q-pr-md
.text-body2: strong {{rel.label}}
.text-caption {{rel.caption}}
wiki-icon(:name='rel.icon')
.page-sidebar(
v-if='showSidebar'
:style='siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`'

@ -33,7 +33,13 @@ export default defineConfig(({ mode }) => {
},
plugins: [
vue({
template: { transformAssetUrls }
template: {
transformAssetUrls,
// -> `iconify-icon` is a custom element registered by its package, not a Vue component
compilerOptions: {
isCustomElement: tag => tag === 'iconify-icon'
}
}
}),
quasar({
autoImportComponentCase: 'kebab',
@ -52,7 +58,7 @@ export default defineConfig(({ mode }) => {
host: '0.0.0.0',
allowedHosts: true,
port: userConfig.dev?.port,
proxy: ['_api', '_blocks', '_site', '_thumb', '_user'].reduce((result, key) => {
proxy: ['_api', '_blocks', '_icons', '_site', '_thumb', '_user'].reduce((result, key) => {
result[`/${key}`] = {
target: {
host: '127.0.0.1',

Loading…
Cancel
Save