mirror of https://github.com/requarks/wiki
parent
d1c41b4111
commit
1786c3a569
@ -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
|
||||
@ -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
|
||||
@ -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
@ -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()
|
||||
@ -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
|
||||
|
||||
@ -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`]
|
||||
})
|
||||
}
|
||||
@ -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>
|
||||
Loading…
Reference in new issue