feat: implement storage targets (wip)

scarlett
NGPixel 3 weeks ago
parent d39e063371
commit 91eede058e
No known key found for this signature in database

@ -63,9 +63,8 @@ scheduler → event emitters), `initHTTPServer()` (Fastify plugins, auth, routes
`SystemIds` passed to each model's `init()` during first-run seeding. `SystemIds` passed to each model's `init()` during first-run seeding.
- `modules/` — pluggable extensions, discovered from disk. Each module is a directory with a - `modules/` — pluggable extensions, discovered from disk. Each module is a directory with a
`definition.yml` (key, title, props/config schema) plus its implementation — e.g. `definition.yml` (key, title, props/config schema) plus its implementation — e.g.
`modules/authentication/local/`. `modules/storage/*` is definition-only so far: the admin area `modules/authentication/local/`. `modules/storage/*` ships `db` and `disk` — see
stores a configuration per site and module, but no `storage.ts` exists yet and nothing reads or [Storage targets](#storage-targets).
writes content through a target — pages and assets go straight to the database.
- `tasks/simple/` — jobs run in-process by the scheduler; each exports `task()`. File name is - `tasks/simple/` — jobs run in-process by the scheduler; each exports `task()`. File name is
kebab-case, the task key is its camelCase form. kebab-case, the task key is its camelCase form.
- `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal - `tasks/workers/` — CPU-bound jobs run in a worker thread via `worker.ts`, which boots a minimal
@ -162,6 +161,23 @@ npm run build # rollup → blocks/compiled/
The API is browsable via Swagger UI at `http://localhost:3000/_api` in a running instance. Default The API is browsable via Swagger UI at `http://localhost:3000/_api` in a running instance. Default
admin login is `admin@example.com` / `12345678`. admin login is `admin@example.com` / `12345678`.
### How far to go verifying a change
Match the check to the size of the change. `npm run build`, `npx oxlint` and `npm run typecheck` are
seconds each and are the right check for nearly everything.
**Do not stand up a throwaway instance and drive a headless browser to look at a small change.** That
means booting a backend against a scratch database, seeding it, and screenshotting through
`/usr/bin/chromium` — a good ten minutes of setup that a moved border, a colour, a spacing tweak or a
renamed label does not earn. Read the rule you wrote, trust the build, and say what you changed.
It is worth the setup for a **new** piece of UI whose markup has to meet a stylesheet written
elsewhere, where being wrong means shipping something visibly broken — a component reusing existing
content classes is the case that has actually gone wrong. Also for a flow with real state to exercise
(a login, an upload, a save), where a screenshot answers a question reading cannot.
See the `wikijs-isolated-test-instance` memory for how to boot one when it IS warranted.
## TypeScript (backend) ## TypeScript (backend)
The backend is entirely **TypeScript 7** (the native Go compiler — `tsc` is a platform binary, not a The backend is entirely **TypeScript 7** (the native Go compiler — `tsc` is a platform binary, not a
@ -382,6 +398,111 @@ Consequences worth knowing:
[Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older [Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older
files are on their way out. files are on their way out.
### Storage targets
A storage target is one module from `modules/storage/<key>/` configured for one site. Two modules
ship, both with a real `storage.ts`: `db`, enabled on every site and impossible to turn off, and
`disk`, which mirrors the wiki's tree at `<root>/<locale>/<folders…>/<file>` — the configured root is
the site's own folder, since a target belongs to one site, so two sites must not share a path. S3,
Azure, GCS, git and SFTP were removed rather than left as definitions with nothing behind them; a
new module is a directory with a `definition.yml` and a `storage.ts` exporting the `StorageModule`
contract, and `hasImplementation` gates both dispatch and the admin area's action buttons on the
latter existing.
**Content is written to every target that claims it, and read from one.** Those are two separate
questions with two separate answers, and conflating them is the way to get this wrong:
- **Written** — a target's `contentTypes.activeTypes` says what is stored there, and a site may store
the same kind in several places at once. An upload goes to *all* of them; the admin area's
**Targets** tab is where that is set, per target.
- **Read**`assetDelivery.servedTypes` names the content types a reader's request is answered from
that target, at most one target per type across the site. The **Content Delivery** tab sets it, and
a target may only be nominated for a type it also stores (`validateTarget` refuses the pair). Pages
are never nominated: a page is read from its own row, always.
**Only an explicit nomination moves delivery.** A type nobody has been nominated for is served
from the database (`deliveryTargetsFor`), never from whichever other target happens to be enabled
— enabling one says where content is *written*, and a target enabled after an upload holds none of
the existing files anyway. Disabling a target therefore puts delivery back, and `updateTarget`
clears `servedTypes` as it goes so that re-enabling it later does not silently take the content
type with it. The database gives the role up only by not holding the type at all.
So neither an asset nor a page records where its bytes went — there is no single place — and each
target derives where its own copy sits from the tree, the same way for both. `resolveTargetFor` and
`assets.storageInfo` are gone; `writeTargetsFor` and `deliveryTargetsFor` replace them.
**Which content type a file is, is one answer per site, not per target.** `large` is a category of
its own rather than a modifier — that is what lets a target take the 40 MB video without also taking
every thumbnail — and the size at which it starts lives in the site's config as
`storage.largeThreshold` (`storage.largeThresholdFor`, the admin area's **Configuration** tab). It
has to be shared: a file the disk target called large and the database called an image would be
claimed by neither target, or by both. The whole storage configuration of a site — the site-wide
settings and every target — is read and written as one, through `GET`/`PUT /sites/:siteId/storage`.
Everything else follows from that:
- **A write must succeed everywhere; a read may fall back.** `storage.putAsset` fans out and throws if
any target refuses, failing the upload — an asset may have no database copy, so a half-stored one
must not be reported as saved. `getAsset` tries the nominated source and then every other target
holding the content, database last, because a target enabled after an upload never received it.
Pages are gentler still: `mirrorPage` / `removePage` / `relocatePage` log a target that could not
keep up and carry on, since the database always has the page.
- **The disk target's `exportAll` is a copy, not a move.** It writes out everything it is configured
to hold, overwriting, and touches neither the database nor any record — which is how content that
predates the target being enabled gets onto it.
- **The move is the database target's `offloadUnchecked`.** Turning a target on only affects what is
uploaded from then on, so a site that unticks a content type on the database is still carrying
every file of that kind ever uploaded — and carrying it unreachably, since a target is only read
for a type it stores. That action reads each of those out of its row, writes it to every enabled
target holding the type, **reads it back to check** and only then clears the `data` column. An
asset with no destination keeps its copy and is reported as stranded: nothing is cleared that is
not known to be somewhere else, because this is the only copy of the bytes. Metadata is untouched
throughout — `data` is the one column it empties.
- **Renames have files to move on every target.** `assets.relocateAssets` takes the old location from
the caller and reads the new one off the tree; `tree.renameFolder` does the same for everything
beneath a renamed folder, pages included (moved, not rewritten — nothing changed).
- **Thumbnails always stay in the database** (`assets.preview`) — the file manager asks for a
screenful at a time, and a slow target must not cost a wiki its file browser.
- **`pages.adoptStoredPage` and `assets.adoptStoredFile` are the way back in**, for the disk
target's two import actions. Both take an `overwrite` flag, and it is the only thing separating
them: `importAll` leaves a path the wiki already has alone, because reconciling a file changed on
both sides is a merge and belongs to a target with history, while `importAllOverwrite` lets the
folder win — for a restore, where there is nothing to reconcile. A page is replaced through
`updatePage`, so its previous version is in its history; an asset has none, and `replace` also
dispatches the new bytes to every write target, since the copy a reader is served is usually the
database's. Imported content is rendered with **no script or style permission** whoever ran the
import, since the file need not have been written by them.
- **On import a file is a page if its extension is reserved, or if it declares an `editor`** in its
front matter. A text page is front matter plus the source; a **JSON** page — a redirection today —
is one JSON document with the metadata at its top level and the source under `content`.
**Pages and assets share one folder, and the site's `pageExtensions` is what keeps them apart.** A
page is stored under its editor's extension (`md`, `html`, `adoc`, `json`), while the tree holds its
name without one — so page `notes/readme` and an attachment called `readme.md` are different names to
the wiki and the same file on disk. Three rules stop them ever meeting, and all three live in the
models rather than in the storage module:
- **`pageExtensions` are reserved.** `assets.upload` refuses an attachment using one — a `.md` file is
a page, so uploading it as an attachment is a mistake rather than a collision. This is the whole of
it on a default site (`md,html,txt`).
- **Both sides check anyway.** For an extension a site has taken *off* that list,
`assets.guardAgainstPageCollision` and `pages.guardAgainstAssetCollision` refuse whichever arrives
second. Extensions must match to collide: `readme.pdf` sits happily beside the page `readme`.
- **Nothing guesses at a name.** `StoragePageRef` carries `contentType`, so a delete or a move touches
exactly one file — a target that tried each extension in turn would delete the attachment next door.
**A target's `state` column is how it is behaving, not how it is configured.** `{ status: 'healthy' |
'warning' | 'error', message, updatedAt }`, written only by `storage.recordState` as the model
dispatches to a module, absent from `StorageTargetInput`, and reported by the Status card. `error` is
a failure that was raised to whoever asked (a refused upload); `warning` is one that was swallowed
because the request succeeded anyway (a page copy that could not be written). The last operation
wins — a later success clears an earlier failure, which is what makes a full disk that gets emptied
stop reporting itself without anybody dismissing anything. Nothing probes a target proactively, so a
misconfigured one reads healthy until something is actually asked of it.
Not to be confused with `<dataPath>/cache/files`, the serving cache in the assets model. That one is
derived and swept; a storage target is where content actually lives.
### Icons ### Icons
Icons come from **Iconify** and are referenced the way Iconify references them — `<prefix>:<name>`, Icons come from **Iconify** and are referenced the way Iconify references them — `<prefix>:<name>`,

@ -1,4 +1,4 @@
import { CONTENT_TYPES } from '../../models/storage.ts' import { CONTENT_TYPES, STORAGE_TARGET_STATUSES } from '../../models/storage.ts'
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> { export async function registerSchemas(app: FastifyInstance): Promise<void> {
@ -40,7 +40,8 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}, },
contentTypes: { contentTypes: {
type: 'object', type: 'object',
description: 'Which kinds of content this target holds.', description:
'Which kinds of content are written to this target. Not a choice between targets: a site may store the same kind in several places at once, and every one of them receives a copy.',
properties: { properties: {
activeTypes: { activeTypes: {
type: 'array', type: 'array',
@ -48,10 +49,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'string', type: 'string',
enum: [...CONTENT_TYPES] enum: [...CONTENT_TYPES]
} }
},
largeThreshold: {
type: 'string',
description: 'Size above which an asset counts as a large file, e.g. `5MB`.'
} }
} }
}, },
@ -71,42 +68,15 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}, },
directAccess: { directAccess: {
type: 'boolean' 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: { servedTypes: {
type: 'boolean' type: 'array',
}, description:
enabled: { 'The content types a request for a file is answered from this target. A subset of `contentTypes.activeTypes`, since a target can only serve back what it was asked to store, and across a site each type names at most one target.',
type: 'boolean' items: {
} type: 'string',
} enum: [...CONTENT_TYPES]
}, }
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.'
} }
} }
}, },
@ -147,6 +117,26 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
} }
} }
} }
},
state: {
type: 'object',
description:
'How the target is behaving, as opposed to how it is configured. Read-only and absent from `StorageTargetInput`: it records the outcome of the last operation the wiki asked of this target, not anything an administrator sets. `warning` is an operation that failed without being refused - a page copy that could not be written - and `error` is one that was reported to whoever asked, such as a failed upload. A subsequent success clears either.',
properties: {
status: {
type: 'string',
enum: [...STORAGE_TARGET_STATUSES]
},
message: {
type: 'string',
description: 'What went wrong. Empty when healthy.'
},
updatedAt: {
type: ['string', 'null'],
description:
'When the status was last written, or null for a target that has not been asked to do anything yet.'
}
}
} }
} }
}) })
@ -166,7 +156,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
isEnabled: { isEnabled: {
type: 'boolean', type: 'boolean',
description: description:
'The database target cannot be disabled, and a target with a pending setup cannot be enabled.' 'The database target cannot be disabled, and a module without an implementation cannot be enabled.'
}, },
contentTypes: { contentTypes: {
type: 'object', type: 'object',
@ -177,10 +167,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'string', type: 'string',
enum: [...CONTENT_TYPES] enum: [...CONTENT_TYPES]
} }
},
largeThreshold: {
type: 'string',
maxLength: 32
} }
} }
}, },
@ -193,16 +179,14 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}, },
directAccess: { directAccess: {
type: 'boolean' type: 'boolean'
} },
} servedTypes: {
}, type: 'array',
versioning: { description: 'Refused for a content type this target is not also configured to store.',
type: 'object', items: {
description: type: 'string',
'Ignored by a module that does not support versioning or that forces it on — the module decides, not the client.', enum: [...CONTENT_TYPES]
properties: { }
enabled: {
type: 'boolean'
} }
} }
}, },

@ -6,18 +6,18 @@ import type { StorageTargetInput } from '../models/storage.ts'
*/ */
async function routes(app: FastifyInstance) { async function routes(app: FastifyInstance) {
/** /**
* LIST SITE STORAGE TARGETS * GET SITE STORAGE CONFIGURATION
*/ */
app.get<{ Params: { siteId: string } }>( app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/storage/targets', '/sites/:siteId/storage',
{ {
config: { config: {
permissions: ['manage:system'] permissions: ['manage:system']
}, },
schema: { schema: {
summary: 'List the storage targets of a site', summary: 'Get the storage configuration of a site',
description: 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.', 'The site-wide settings, plus 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. Where a given file is written and where it is read from are both derived from this configuration rather than recorded per file, so changing it changes where content is looked for, not where it already sits.',
tags: ['Storage'], tags: ['Storage'],
params: { params: {
type: 'object', type: 'object',
@ -31,9 +31,19 @@ async function routes(app: FastifyInstance) {
}, },
response: { response: {
200: { 200: {
description: 'List of storage targets', description: 'Storage configuration of the site',
type: 'array', type: 'object',
items: { $ref: 'StorageTarget#' } properties: {
largeThreshold: {
type: 'string',
description:
'Size at or above which an asset counts as a large file, e.g. `5MB`. One answer for the whole site: a file has to be the same kind of thing to every target.'
},
targets: {
type: 'array',
items: { $ref: 'StorageTarget#' }
}
}
} }
} }
} }
@ -43,23 +53,29 @@ async function routes(app: FastifyInstance) {
if (!site) { if (!site) {
return reply.notFound('Site does not exist.') return reply.notFound('Site does not exist.')
} }
return WIKI.models.storage.getSiteTargets(req.params.siteId) return {
largeThreshold: WIKI.models.storage.largeThresholdFor(req.params.siteId),
targets: await WIKI.models.storage.getSiteTargets(req.params.siteId)
}
} }
) )
/** /**
* UPDATE SITE STORAGE TARGETS * UPDATE SITE STORAGE CONFIGURATION
*/ */
app.put<{ Params: { siteId: string }; Body: { targets: StorageTargetInput[] } }>( app.put<{
'/sites/:siteId/storage/targets', Params: { siteId: string }
Body: { largeThreshold?: string; targets?: StorageTargetInput[] }
}>(
'/sites/:siteId/storage',
{ {
config: { config: {
permissions: ['manage:system'] permissions: ['manage:system']
}, },
schema: { schema: {
summary: 'Update the storage targets of a site', summary: 'Update the storage configuration of a site',
description: 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.', 'Only the targets listed are affected, and within each of them only the fields provided. Everything is validated before any of it is written, so a rejected request changes nothing.',
tags: ['Storage'], tags: ['Storage'],
params: { params: {
type: 'object', type: 'object',
@ -73,8 +89,12 @@ async function routes(app: FastifyInstance) {
}, },
body: { body: {
type: 'object', type: 'object',
required: ['targets'],
properties: { properties: {
largeThreshold: {
type: 'string',
maxLength: 32,
description: 'A size such as `5MB`. Applies to every target of the site.'
},
targets: { targets: {
type: 'array', type: 'array',
items: { $ref: 'StorageTargetInput#' } items: { $ref: 'StorageTargetInput#' }
@ -83,7 +103,7 @@ async function routes(app: FastifyInstance) {
}, },
response: { response: {
200: { 200: {
description: 'Storage targets updated successfully', description: 'Storage configuration updated successfully',
type: 'object', type: 'object',
properties: { properties: {
ok: { ok: {
@ -110,9 +130,13 @@ async function routes(app: FastifyInstance) {
// -> Validated as a whole first: a partially applied storage configuration is worse than a // -> 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 // refused one, since the admin area saves every target at once
const invalidConfig = WIKI.models.storage.validateSiteConfig(req.body)
if (invalidConfig) {
return reply.badRequest(invalidConfig)
}
const current = await WIKI.models.storage.getSiteTargets(req.params.siteId) const current = await WIKI.models.storage.getSiteTargets(req.params.siteId)
const patches = [] const patches = []
for (const patch of req.body.targets) { for (const patch of req.body.targets ?? []) {
const target = current.find((t) => t.id === patch.id) const target = current.find((t) => t.id === patch.id)
if (!target) { if (!target) {
return reply.notFound(`Storage target ${patch.id} does not exist.`) return reply.notFound(`Storage target ${patch.id} does not exist.`)
@ -124,6 +148,8 @@ async function routes(app: FastifyInstance) {
patches.push({ target, patch }) patches.push({ target, patch })
} }
await WIKI.models.storage.updateSiteConfig(req.params.siteId, req.body)
let updated = 0 let updated = 0
for (const { target, patch } of patches) { for (const { target, patch } of patches) {
if (await WIKI.models.storage.updateTarget(req.params.siteId, target, patch)) { if (await WIKI.models.storage.updateTarget(req.params.siteId, target, patch)) {
@ -133,7 +159,7 @@ async function routes(app: FastifyInstance) {
return { return {
ok: true, ok: true,
message: 'Storage targets updated successfully.', message: 'Storage configuration updated successfully.',
updated updated
} }
} }
@ -151,7 +177,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Run an action on a storage target', summary: 'Run an action on a storage target',
description: 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.', 'The actions a target offers are listed with it, and only an enabled target can run one. An action runs to completion before the request is answered, so moving a large amount of content can take a while.',
tags: ['Storage'], tags: ['Storage'],
params: { params: {
type: 'object', type: 'object',
@ -201,103 +227,18 @@ async function routes(app: FastifyInstance) {
if (!target.actions.some((act) => act.handler === req.params.action)) { if (!target.actions.some((act) => act.handler === req.params.action)) {
return reply.badRequest(`${target.title} has no "${req.params.action}" action.`) return reply.badRequest(`${target.title} has no "${req.params.action}" action.`)
} }
// -> An action may create content, and content records who authored it. An API key is not a
try { // who, so these are reserved to a logged-in administrator.
await WIKI.models.storage.executeAction(target, req.params.action) const actorId = req.session?.authenticated ? req.session.user?.id : null
} catch (err: any) { if (!actorId) {
WIKI.logger.warn(err) return reply.unauthorized('Running a storage action requires a logged in user.')
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 { try {
const state = await WIKI.models.storage.runSetup(target, req.body) const message = await WIKI.models.storage.executeAction(target, req.params.action, actorId)
return { return {
ok: true, ok: true,
message: 'Setup step completed successfully.', message: message ?? 'Action completed successfully.'
state
} }
} catch (err: any) { } catch (err: any) {
WIKI.logger.warn(err) WIKI.logger.warn(err)
@ -305,76 +246,6 @@ async function routes(app: FastifyInstance) {
} }
} }
) )
/**
* 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 export default routes

@ -41,7 +41,6 @@ CREATE TABLE "assets" (
"updatedAt" timestamp DEFAULT now() NOT NULL, "updatedAt" timestamp DEFAULT now() NOT NULL,
"data" bytea, "data" bytea,
"preview" bytea, "preview" bytea,
"storageInfo" jsonb,
"authorId" uuid NOT NULL, "authorId" uuid NOT NULL,
"siteId" uuid NOT NULL "siteId" uuid NOT NULL
); );
@ -316,7 +315,6 @@ CREATE TABLE "storage" (
"isEnabled" boolean DEFAULT false NOT NULL, "isEnabled" boolean DEFAULT false NOT NULL,
"contentTypes" jsonb DEFAULT '{}' NOT NULL, "contentTypes" jsonb DEFAULT '{}' NOT NULL,
"assetDelivery" jsonb DEFAULT '{}' NOT NULL, "assetDelivery" jsonb DEFAULT '{}' NOT NULL,
"versioning" jsonb DEFAULT '{}' NOT NULL,
"config" jsonb DEFAULT '{}' NOT NULL, "config" jsonb DEFAULT '{}' NOT NULL,
"state" jsonb DEFAULT '{}' NOT NULL, "state" jsonb DEFAULT '{}' NOT NULL,
"siteId" uuid NOT NULL "siteId" uuid NOT NULL
@ -456,4 +454,4 @@ ALTER TABLE "tags" ADD CONSTRAINT "tags_siteId_sites_id_fkey" FOREIGN KEY ("site
ALTER TABLE "tree" ADD CONSTRAINT "tree_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint ALTER TABLE "tree" ADD CONSTRAINT "tree_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "userGroups" ADD CONSTRAINT "userGroups_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;--> statement-breakpoint ALTER TABLE "userGroups" ADD CONSTRAINT "userGroups_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "userGroups" ADD CONSTRAINT "userGroups_groupId_groups_id_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE;--> statement-breakpoint ALTER TABLE "userGroups" ADD CONSTRAINT "userGroups_groupId_groups_id_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "userKeys" ADD CONSTRAINT "userKeys_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id"); ALTER TABLE "userKeys" ADD CONSTRAINT "userKeys_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id");

@ -651,19 +651,6 @@
"schema": "public", "schema": "public",
"table": "assets" "table": "assets"
}, },
{
"type": "jsonb",
"typeSchema": null,
"notNull": false,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "storageInfo",
"entityType": "columns",
"schema": "public",
"table": "assets"
},
{ {
"type": "uuid", "type": "uuid",
"typeSchema": null, "typeSchema": null,
@ -3306,19 +3293,6 @@
"schema": "public", "schema": "public",
"table": "storage" "table": "storage"
}, },
{
"type": "jsonb",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": "'{}'",
"generated": null,
"identity": null,
"name": "versioning",
"entityType": "columns",
"schema": "public",
"table": "storage"
},
{ {
"type": "jsonb", "type": "jsonb",
"typeSchema": null, "typeSchema": null,
@ -5746,4 +5720,4 @@
} }
], ],
"renames": [] "renames": []
} }

@ -99,9 +99,11 @@ export const assets = pgTable(
meta: jsonb().notNull().default({}), meta: jsonb().notNull().default({}),
createdAt: timestamp().notNull().defaultNow(), createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().defaultNow(),
// -> Set only while the database is one of the targets configured to store this kind of file.
// An asset is written to every target that claims it, and each derives where its own copy
// sits from the tree, so there is nothing to record here about where the bytes went.
data: bytea(), data: bytea(),
preview: bytea(), preview: bytea(),
storageInfo: jsonb(),
authorId: uuid() authorId: uuid()
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
@ -652,16 +654,19 @@ export const storage = pgTable(
// -> Directory name under `modules/storage`, one row per module per site // -> Directory name under `modules/storage`, one row per module per site
module: varchar({ length: 255 }).notNull(), module: varchar({ length: 255 }).notNull(),
isEnabled: boolean().notNull().default(false), isEnabled: boolean().notNull().default(false),
// -> `{ activeTypes: string[], largeThreshold: string }` // -> `{ activeTypes: string[] }`, i.e. which kinds of content are written here. What counts as a
// large file is not among them: that is one answer per site, in the site's own config.
contentTypes: jsonb().notNull().default({}), contentTypes: jsonb().notNull().default({}),
// -> `{ streaming: boolean, directAccess: boolean }` // -> `{ streaming: boolean, directAccess: boolean, servedTypes: string[] }`. `servedTypes` names
// the content types a reader's request is answered from this target, and is a subset of
// `contentTypes.activeTypes` — a target can only serve back what it was asked to store.
assetDelivery: jsonb().notNull().default({}), assetDelivery: jsonb().notNull().default({}),
// -> `{ enabled: boolean }`
versioning: jsonb().notNull().default({}),
// -> Values for the props the module declares in its `definition.yml` // -> Values for the props the module declares in its `definition.yml`
config: jsonb().notNull().default({}), config: jsonb().notNull().default({}),
// -> Where the module stands, as opposed to how it is configured: `{ setup: 'notconfigured' | // -> `{ status: 'healthy' | 'warning' | 'error', message: string, updatedAt: string | null }`:
// 'pendinginstall' | 'configured' }` for a module that has a setup process to go through. // how the target is actually behaving, as opposed to how it is configured. Written by the
// storage model as it dispatches to the module — never by the admin area, which is why it is
// absent from the storage PUT — and reported by the Status card on the target's page.
state: jsonb().notNull().default({}), state: jsonb().notNull().default({}),
siteId: uuid() siteId: uuid()
.notNull() .notNull()

@ -345,7 +345,7 @@
"admin.general.logoUploadFailed": "Failed to upload the site logo.", "admin.general.logoUploadFailed": "Failed to upload the site logo.",
"admin.general.logoUploadSuccess": "Site logo uploaded successfully.", "admin.general.logoUploadSuccess": "Site logo uploaded successfully.",
"admin.general.pageExtensions": "Page Extensions", "admin.general.pageExtensions": "Page Extensions",
"admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that address a page. For example, adding md redirects /foobar.md to /foobar.", "admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that address a page. For example, adding md redirects /foobar.md to /foobar. These extensions are reserved for pages: a file using one cannot be uploaded as an asset.",
"admin.general.ratingsOff": "Off", "admin.general.ratingsOff": "Off",
"admin.general.ratingsStars": "Stars", "admin.general.ratingsStars": "Stars",
"admin.general.ratingsThumbs": "Thumbs", "admin.general.ratingsThumbs": "Thumbs",
@ -832,124 +832,54 @@
"admin.ssl.writableConfigFileWarning": "Note that your config file must be writable in order to persist ports configuration.", "admin.ssl.writableConfigFileWarning": "Note that your config file must be writable in order to persist ports configuration.",
"admin.stats.title": "Statistics", "admin.stats.title": "Statistics",
"admin.storage.actionFailed": "Failed to run {action}.", "admin.storage.actionFailed": "Failed to run {action}.",
"admin.storage.actionRun": "Run",
"admin.storage.actionSuccess": "{action} completed successfully.", "admin.storage.actionSuccess": "{action} completed successfully.",
"admin.storage.actions": "Actions", "admin.storage.actions": "Actions",
"admin.storage.actionsInactiveWarn": "You must enable this storage target and apply changes before you can run actions.", "admin.storage.actionsInactiveWarn": "You must enable this storage target before you can run actions.",
"admin.storage.addTarget": "Add Storage Target",
"admin.storage.assetDelivery": "Asset Delivery",
"admin.storage.assetDeliveryHint": "Select how uploaded assets should be delivered to the user. Note that not all storage origins support asset delivery and some can only be used for backup purposes.",
"admin.storage.assetDirectAccess": "Direct Access",
"admin.storage.assetDirectAccessHint": "Assets are accessed directly by the user using a secure / signed link. When enabled, takes priority over file streaming.",
"admin.storage.assetDirectAccessNotSupported": "Not supported by this storage target.",
"admin.storage.assetStreaming": "File Streaming",
"admin.storage.assetStreamingHint": "Assets will be streamed from the storage target, through the server, to the user.",
"admin.storage.assetStreamingNotSupported": "Not supported by this storage target.",
"admin.storage.assetsOnly": "Assets Only", "admin.storage.assetsOnly": "Assets Only",
"admin.storage.cancelSetup": "Cancel",
"admin.storage.config": "Configuration", "admin.storage.config": "Configuration",
"admin.storage.configHint": "Settings that apply to every storage target of this site.",
"admin.storage.confirmDisable": "Save and disable this target?",
"admin.storage.confirmEnable": "Save and enable this target?",
"admin.storage.confirmToggleHint": "Any unsaved changes to this site's storage configuration are applied as well.",
"admin.storage.contentTypeDocuments": "Documents", "admin.storage.contentTypeDocuments": "Documents",
"admin.storage.contentTypeDocumentsHint": "Text or presentation documents in PDF, TXT, Word, Excel and Powerpoint formats.", "admin.storage.contentTypeDocumentsHint": "Text or presentation documents in PDF, TXT, Word, Excel and Powerpoint formats.",
"admin.storage.contentTypeImages": "Images", "admin.storage.contentTypeImages": "Images",
"admin.storage.contentTypeImagesHint": "Image Assets in JPG, PNG, GIF, WebP and SVG formats.", "admin.storage.contentTypeImagesHint": "Image Assets in JPG, PNG, GIF, WebP and SVG formats.",
"admin.storage.contentTypeLargeFiles": "Large Files", "admin.storage.contentTypeLargeFiles": "Large Files",
"admin.storage.contentTypeLargeFilesDBWarn": "For performance reasons, large files should not be stored in the database. Consider using another storage type for these files.", "admin.storage.contentTypeLargeFilesDBWarn": "For performance reasons, large files should not be stored in the database. Consider using another storage target for these files.",
"admin.storage.contentTypeLargeFilesHint": "Large files such as videos, zip archives and binaries. Pages never fall into this category, irrespective of their size.", "admin.storage.contentTypeLargeFilesHint": "Large files such as videos, zip archives and binaries. Pages never fall into this category, irrespective of their size. Set the threshold in the Configuration tab.",
"admin.storage.contentTypeLargeFilesThreshold": "Size Threshold",
"admin.storage.contentTypeOthers": "Others", "admin.storage.contentTypeOthers": "Others",
"admin.storage.contentTypeOthersHint": "Any other file types that don't match the other categories.", "admin.storage.contentTypeOthersHint": "Any other file types that don't match the other categories.",
"admin.storage.contentTypePages": "Pages", "admin.storage.contentTypePages": "Pages",
"admin.storage.contentTypePagesHint": "Page content source, in Markdown, HTML or JSON format depending on the editor.", "admin.storage.contentTypePagesHint": "Page content source, in Markdown, HTML or JSON format depending on the editor.",
"admin.storage.contentTypePagesSource": "Pages are always served from the database, so this cannot be turned off.",
"admin.storage.contentTypes": "Content Types", "admin.storage.contentTypes": "Content Types",
"admin.storage.contentTypesHint": "Select the type of content that should be stored to this storage target:", "admin.storage.contentTypesHint": "Select the type of content that is written to this storage target. The same type can be stored on several targets at once.",
"admin.storage.currentState": "Current State", "admin.storage.delivery": "Content Delivery",
"admin.storage.deliveryPaths": "Delivery Paths", "admin.storage.deliveryHint": "Choose which storage target each kind of content is served from when a reader requests a file.",
"admin.storage.deliveryPathsLegend": "Legend:", "admin.storage.deliveryNoTarget": "No enabled storage target is configured to store this content type.",
"admin.storage.deliveryPathsPushToOrigin": "Push to Origin", "admin.storage.deliveryPagesHint": "Pages are always served from the database.",
"admin.storage.deliveryPathsUser": "User", "admin.storage.deliveryRelationHint": "A target can only be chosen here for a content type it is also configured to store, under Targets.",
"admin.storage.deliveryPathsUserRequest": "User Request",
"admin.storage.destroyConfirm": "Confirm Setup Reset",
"admin.storage.destroyConfirmInfo": "Are you sure you want to reset the storage target setup configuration? Note that this action cannot be undone and you will need to start the setup process over.",
"admin.storage.destroyingSetup": "Resetting storage target setup configuration...",
"admin.storage.enabled": "Enabled",
"admin.storage.enabledForced": "Cannot be disabled on the database target.",
"admin.storage.enabledHint": "Should this storage target be used for storing and accessing assets.",
"admin.storage.errorMsg": "Error Message",
"admin.storage.finishSetup": "Finish Setup",
"admin.storage.githubAccTypeOrg": "Organization",
"admin.storage.githubAccTypePersonal": "Personal",
"admin.storage.githubFinish": "Once you have installed the application on the GitHub repository of your choice, click the Finish Setup button below to validate the installation and start using it. Otherwise, click Destroy to delete any pending configuration and start over.",
"admin.storage.githubInstallApp": "Setup GitHub Connection - Step 2",
"admin.storage.githubInstallAppHint": "On the next screen, you will be prompted to install the app you just created onto one or more repositories. You may select a single one or all repositories.",
"admin.storage.githubOrg": "GitHub Organization",
"admin.storage.githubOrgHint": "Enter the GitHub organization account to be used.",
"admin.storage.githubPreparingManifest": "Preparing manifest...",
"admin.storage.githubPublicUrl": "Wiki Public URL",
"admin.storage.githubPublicUrlHint": "Enter the full URL to your wiki (e.g. https://wiki.example.com). Note that your wiki MUST be accessible from the internet!",
"admin.storage.githubRedirecting": "Redirecting to GitHub...",
"admin.storage.githubRepo": "GitHub Repository",
"admin.storage.githubRepoCreating": "Creating GitHub Repository...",
"admin.storage.githubRepoHint": "Enter the name of the repository to create on GitHub and use for this wiki:",
"admin.storage.githubSetupContinue": "Continue Setup",
"admin.storage.githubSetupDestroyFailed": "Failed to destroy setup configuration.",
"admin.storage.githubSetupDestroySuccess": "Setup configuration has been reset successfully.",
"admin.storage.githubSetupFailed": "GitHub Setup failed.",
"admin.storage.githubSetupInstallApp": "GitHub Connection Setup - Step 2",
"admin.storage.githubSetupInstallAppInfo": "On the next screen, you'll be prompted to install the app you just created onto one or more GitHub repositories.",
"admin.storage.githubSetupInstallAppReturn": "Once the installation on GitHub is completed, you will need to manually return to this page to finish the setup.",
"admin.storage.githubSetupInstallAppSelect": "IMPORTANT: Select only the repository that will be used to sync with this wiki.",
"admin.storage.githubSetupSuccess": "Success! Wiki.js is now connected to GitHub.",
"admin.storage.githubVerifying": "Verifying GitHub configuration...",
"admin.storage.inactiveTarget": "Inactive", "admin.storage.inactiveTarget": "Inactive",
"admin.storage.lastSync": "Last synchronization {time}", "admin.storage.largeThreshold": "Large File Size Threshold",
"admin.storage.lastSyncAttempt": "Last attempt was {time}", "admin.storage.largeThresholdHint": "Assets of this size or larger count as large files, whatever their type, such as videos, zip archives and binaries. Pages never fall into this category, irrespective of their size.",
"admin.storage.loadFailed": "Failed to load storage configuration.", "admin.storage.loadFailed": "Failed to load storage configuration.",
"admin.storage.missingOrigin": "Missing Origin",
"admin.storage.noActions": "This storage target has no actions that you can execute.", "admin.storage.noActions": "This storage target has no actions that you can execute.",
"admin.storage.noConfigOption": "This storage target has no configuration options you can modify.", "admin.storage.noConfigOption": "This storage target has no configuration options you can modify.",
"admin.storage.noSyncModes": "This storage target has no synchronization options you can modify.",
"admin.storage.noTarget": "You don't have any active storage target.",
"admin.storage.notConfigured": "Not Configured", "admin.storage.notConfigured": "Not Configured",
"admin.storage.pagesAndAssets": "Pages and Assets", "admin.storage.pagesAndAssets": "Pages and Assets",
"admin.storage.pagesOnly": "Pages Only", "admin.storage.pagesOnly": "Pages Only",
"admin.storage.saveFailed": "Failed to save storage configuration.", "admin.storage.saveFailed": "Failed to save storage configuration.",
"admin.storage.saveSuccess": "Storage configuration saved successfully.", "admin.storage.saveSuccess": "Storage configuration saved successfully.",
"admin.storage.setup": "Setup", "admin.storage.stateActive": "Healthy",
"admin.storage.setupConfiguredHint": "This module is already configured. You can uninstall this module to start over.", "admin.storage.stateError": "Error",
"admin.storage.setupHint": "This module requires a setup process to be completed in order to use it. Follow the instructions below to get started.", "admin.storage.stateInactive": "Not in use",
"admin.storage.setupRequired": "Setup required", "admin.storage.stateNoContentTypes": "No content type",
"admin.storage.startSetup": "Start Setup", "admin.storage.stateWarning": "Degraded",
"admin.storage.status": "Status", "admin.storage.status": "Status",
"admin.storage.subtitle": "Set backup and sync targets for your content", "admin.storage.subtitle": "Choose where the content of your wiki is stored and served from",
"admin.storage.sync": "Synchronization",
"admin.storage.syncDirBi": "Bi-directional",
"admin.storage.syncDirBiHint": "In bi-directional mode, content is first pulled from the storage target. Any newer content overwrites local content. New content since last sync is then pushed to the storage target, overwriting any content on target if present.",
"admin.storage.syncDirPull": "Pull from target",
"admin.storage.syncDirPullHint": "Content is always pulled from the storage target, overwriting any local content which already exists. This choice is usually reserved for single-use content import. Caution with this option as any local content will always be overwritten!",
"admin.storage.syncDirPush": "Push to target",
"admin.storage.syncDirPushHint": "Content is always pushed to the storage target, overwriting any existing content. This is safest choice for backup scenarios.",
"admin.storage.syncDirection": "Sync Direction",
"admin.storage.syncDirectionSubtitle": "Choose how content synchronization is handled for this storage target.",
"admin.storage.syncSchedule": "Sync Schedule",
"admin.storage.syncScheduleCurrent": "Currently set to every {schedule}.",
"admin.storage.syncScheduleDefault": "The default is every {schedule}.",
"admin.storage.syncScheduleHint": "For performance reasons, this storage target synchronize changes on an interval-based schedule, instead of on every change. Define at which interval should the synchronization occur.",
"admin.storage.targetConfig": "Target Configuration",
"admin.storage.targetState": "This storage target is {state}",
"admin.storage.targetStateActive": "active",
"admin.storage.targetStateInactive": "inactive",
"admin.storage.targets": "Targets", "admin.storage.targets": "Targets",
"admin.storage.title": "Storage", "admin.storage.title": "Storage",
"admin.storage.uninstall": "Uninstall",
"admin.storage.unsupported": "Unsupported",
"admin.storage.useVersioning": "Use Versioning",
"admin.storage.useVersioningHint": "Should previous versions of assets be retained on the storage target.",
"admin.storage.vendor": "Vendor",
"admin.storage.vendorWebsite": "Website",
"admin.storage.versioning": "Asset Versioning",
"admin.storage.versioningForceEnabled": "Cannot be disabled on this storage target.",
"admin.storage.versioningHint": "Asset versioning allows for storage of all previous versions of the same file. Unless you have a requirement to store all versions of uploaded assets, it's recommended to leave this oftion off as it can consume significant storage space over time.",
"admin.storage.versioningNotSupported": "Not supported by this storage target.",
"admin.system.browser": "Browser", "admin.system.browser": "Browser",
"admin.system.browserHint": "The browser name and version.", "admin.system.browserHint": "The browser name and version.",
"admin.system.checkForUpdates": "Check", "admin.system.checkForUpdates": "Check",
@ -1440,11 +1370,13 @@
"common.actions.create": "Create", "common.actions.create": "Create",
"common.actions.deactivate": "Deactivate", "common.actions.deactivate": "Deactivate",
"common.actions.delete": "Delete", "common.actions.delete": "Delete",
"common.actions.disable": "Disable",
"common.actions.discard": "Discard", "common.actions.discard": "Discard",
"common.actions.discardChanges": "Discard Changes", "common.actions.discardChanges": "Discard Changes",
"common.actions.download": "Download", "common.actions.download": "Download",
"common.actions.duplicate": "Duplicate", "common.actions.duplicate": "Duplicate",
"common.actions.edit": "Edit", "common.actions.edit": "Edit",
"common.actions.enable": "Enable",
"common.actions.exit": "Exit", "common.actions.exit": "Exit",
"common.actions.exitEdit": "Exit Edit", "common.actions.exitEdit": "Exit Edit",
"common.actions.fetch": "Fetch", "common.actions.fetch": "Fetch",

@ -1,12 +1,13 @@
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import mime from 'mime' import mime from 'mime'
import { and, desc, eq, inArray, sql } from 'drizzle-orm' import { and, desc, eq, inArray, isNotNull, sql } from 'drizzle-orm'
import { assets as assetsTable, tree as treeTable } from '../db/schema.ts' import { assets as assetsTable, tree as treeTable } from '../db/schema.ts'
import { CustomError, decodeTreePath, encodeTreePath } from '../helpers/common.ts' import { CustomError, decodeTreePath, encodeTreePath } from '../helpers/common.ts'
import { makeImageThumbnail } from '../helpers/images.ts' import { makeImageThumbnail } from '../helpers/images.ts'
import type { Readable } from 'node:stream' import type { Readable } from 'node:stream'
import type { DeletedEntry } from './tree.ts' import type { DeletedEntry } from './tree.ts'
import type { StorageAssetRef } from './storage.ts'
/** How large the file manager renders a preview. Generated once, at upload time. */ /** How large the file manager renders a preview. Generated once, at upload time. */
const THUMBNAIL_SIZE = { width: 320, height: 200 } const THUMBNAIL_SIZE = { width: 320, height: 200 }
@ -161,20 +162,28 @@ function kindOf(mimeType: string, fileExt: string): AssetKind {
/** /**
* Assets model * Assets model
* *
* An asset is a file a user uploaded: its bytes live in the `assets` table, while its name and place * An asset is a file a user uploaded: its name and its place in the site live in the `tree` row, its
* in the site live in the matching `tree` row, which shares its ID. Both are written together an * metadata in the matching `assets` row, which shares its ID. Both are written together an asset
* asset with no tree row would be unreachable, and a tree row with no asset would be a broken link. * with no tree row would be unreachable, and a tree row with no asset would be a broken link.
* *
* Storage targets are not implemented yet, so the database is the only copy but not the one that * Where the *bytes* live is a third thing, and not necessarily the database: they go to whichever
* answers a request for a file. Serving goes through two caches, because `/_files/` is hit by every * storage target the site has configured for a file of that kind and size, and the row records which
* image on every page view and neither half of that lookup needs the database twice: * one took them in `storageInfo`. Nothing here knows what that means for a given target writing,
* reading, moving and deleting all go through `WIKI.models.storage`, and the database is simply the
* target every site starts with. What never leaves this model is the metadata: renaming a file is a
* database write plus a request to the target to follow it.
*
* Serving goes through two caches, because `/_files/` is hit by every image on every page view and
* neither half of that lookup needs to reach the target twice:
* *
* 1. **memory**, holding path metadata for `PATH_CACHE_TTL_MS`, which is what decides the ETag and * 1. **memory**, holding path metadata for `PATH_CACHE_TTL_MS`, which is what decides the ETag and
* answers the conditional requests a browser sends once its own copy goes stale * answers the conditional requests a browser sends once its own copy goes stale
* 2. **disk**, under `<dataPath>/cache/files`, holding the bytes, streamed straight to the response * 2. **disk**, under `<dataPath>/cache/files`, holding the bytes, streamed straight to the response
* *
* Only the database is permanent; both caches are derived and can be deleted at any point, which is * Neither cache is storage: both are derived and can be deleted at any point, which is also what
* also what makes a cold instance correct rather than empty-handed. * makes a cold instance correct rather than empty-handed. The one under `<dataPath>/cache/files` is
* not to be confused with the local file system storage target, which is a place content actually
* lives and is never swept.
*/ */
class Assets { class Assets {
/** Path resolutions, keyed `siteId:path`. Insertion-ordered, so the oldest entry is evictable. */ /** Path resolutions, keyed `siteId:path`. Insertion-ordered, so the oldest entry is evictable. */
@ -197,6 +206,75 @@ class Assets {
return UPLOAD_CONFLICT_BEHAVIORS.has(configured) ? configured : 'overwrite' return UPLOAD_CONFLICT_BEHAVIORS.has(configured) ? configured : 'overwrite'
} }
/**
* Refuse an upload that is really a page, or that would land on one.
*
* A page and an asset occupy the same folder and are stored as the same kind of file, so the two
* name spaces are one. Nothing in the tree sees that a page is `readme` and the file is
* `readme.md`, two different names so it is enforced here, on the way in, and by
* `guardAgainstAssetCollision` coming the other way.
*
* Two rules, in the order an administrator would expect them:
*
* 1. **The site's `pageExtensions` are reserved.** They are the extensions that address a page by
* URL, so a file with one of them is a page, and uploading it as an attachment is a mistake
* rather than a collision refused whether or not a page happens to be there today. This is
* what keeps `.md` out of the file manager on a default site.
* 2. **Otherwise, no landing on a page that is there.** For an extension a site has taken off that
* list, the two can legitimately coexist right up until one would overwrite the other's file.
*
* @throws `assetIsPageExtension` or `assetNameTakenByPage`
*/
private async guardAgainstPageCollision({
siteId,
locale,
folderId,
folderPath,
fileName,
fileExt
}: {
siteId: string
locale: string
folderId?: string | null
folderPath?: string | null
fileName: string
fileExt: string
}): Promise<void> {
const reserved: string[] = WIKI.sites[siteId]?.config?.pageExtensions ?? []
if (fileExt && reserved.includes(fileExt)) {
throw new CustomError(
'assetIsPageExtension',
`.${fileExt} is a page extension on this site, so a file with it cannot be uploaded as an attachment. Create a page instead, or remove ${fileExt} from the site's page extensions.`,
409
)
}
// -> The page this would be the file of, if there is one: same folder, same name without the
// extension. Its own extension has to match too — `readme.pdf` is not the file of the
// markdown page `readme`, and sits happily beside it.
const stem = fileName.slice(0, fileName.length - (fileExt ? fileExt.length + 1 : 0))
if (!stem) {
return
}
const occupant = await WIKI.models.tree.getEntryAt({
siteId,
locale,
parentId: folderId,
parentPath: folderPath,
fileName: stem
})
if (
occupant?.type === 'page' &&
(await WIKI.models.pages.storageFileNameOf(occupant.id)) === fileName
) {
throw new CustomError(
'assetNameTakenByPage',
`The page "${stem}" is stored as ${fileName} here, so a file cannot be uploaded under that name.`,
409
)
}
}
/** /**
* Store an uploaded file. * Store an uploaded file.
* *
@ -230,6 +308,7 @@ class Assets {
throw new CustomError('assetInvalidFileName', 'This file name cannot be used.') throw new CustomError('assetInvalidFileName', 'This file name cannot be used.')
} }
const fileExt = extensionOf(safeName) const fileExt = extensionOf(safeName)
await this.guardAgainstPageCollision({ siteId, locale, folderId, fileName: safeName, fileExt })
// -> The extension decides the type, not the request: the declared one is whatever the client felt // -> The extension decides the type, not the request: the declared one is whatever the client felt
// like sending, and this value is what gets served back to a browser later // like sending, and this value is what gets served back to a browser later
const resolvedMime = mime.getType(safeName) ?? mimeType ?? 'application/octet-stream' const resolvedMime = mime.getType(safeName) ?? mimeType ?? 'application/octet-stream'
@ -272,6 +351,7 @@ class Assets {
return this.replace({ return this.replace({
id: occupant.id, id: occupant.id,
siteId, siteId,
locale,
folderPath: decodeTreePath(occupant.folderPath ?? '') ?? '', folderPath: decodeTreePath(occupant.folderPath ?? '') ?? '',
fileName: occupant.fileName, fileName: occupant.fileName,
title: occupant.title, title: occupant.title,
@ -300,8 +380,10 @@ class Assets {
} }
}) })
const storedName = entry.fileName const storedName = entry.fileName
const folderPath = decodeTreePath(entry.folderPath ?? '') ?? ''
try { try {
// -> The metadata row goes in before the bytes, since the database target writes them into it
await WIKI.db.insert(assetsTable).values({ await WIKI.db.insert(assetsTable).values({
id: entry.id, id: entry.id,
fileName: storedName, fileName: storedName,
@ -309,13 +391,25 @@ class Assets {
kind, kind,
mimeType: resolvedMime, mimeType: resolvedMime,
fileSize: data.length, fileSize: data.length,
data,
preview, preview,
authorId, authorId,
siteId siteId
}) })
await WIKI.models.storage.putAsset(
{
id: entry.id,
siteId,
locale,
folderPath,
fileName: storedName,
kind,
fileSize: data.length
},
data
)
} catch (err) { } catch (err) {
// -> Nothing points at the tree row now, and leaving it would show a file the site cannot serve // -> Nothing points at these now, and leaving them would show a file the site cannot serve
await WIKI.db.delete(assetsTable).where(eq(assetsTable.id, entry.id))
await WIKI.db.delete(treeTable).where(eq(treeTable.id, entry.id)) await WIKI.db.delete(treeTable).where(eq(treeTable.id, entry.id))
throw err throw err
} }
@ -323,7 +417,7 @@ class Assets {
WIKI.models.hooks.emit('asset:upload', { WIKI.models.hooks.emit('asset:upload', {
id: entry.id, id: entry.id,
fileName: storedName, fileName: storedName,
folderPath: decodeTreePath(entry.folderPath ?? '') ?? '', folderPath,
siteId, siteId,
authorId, authorId,
metadata: { fileSize: data.length, mimeType: resolvedMime, kind } metadata: { fileSize: data.length, mimeType: resolvedMime, kind }
@ -336,7 +430,7 @@ class Assets {
kind, kind,
mimeType: resolvedMime, mimeType: resolvedMime,
fileSize: data.length, fileSize: data.length,
folderPath: decodeTreePath(entry.folderPath ?? '') ?? '', folderPath,
title: entry.title, title: entry.title,
hasPreview: Boolean(preview), hasPreview: Boolean(preview),
createdAt: entry.createdAt, createdAt: entry.createdAt,
@ -355,10 +449,15 @@ class Assets {
* The name it keeps is the stored one, which is why the extension and type are the incoming file's: * The name it keeps is the stored one, which is why the extension and type are the incoming file's:
* the two only differ when a browser sent `Photo.PNG` for what is stored as `photo.png`, and the * the two only differ when a browser sent `Photo.PNG` for what is stored as `photo.png`, and the
* sanitized name is what both agree on. * sanitized name is what both agree on.
*
* Which targets hold it are worked out again from the incoming file rather than inherited, since
* the two may not be the same size replacing a thumbnail with a 40 MB one is how a file crosses
* the large-file threshold and starts being stored somewhere else entirely.
*/ */
private async replace({ private async replace({
id, id,
siteId, siteId,
locale,
folderPath, folderPath,
fileName, fileName,
title, title,
@ -371,6 +470,7 @@ class Assets {
}: { }: {
id: string id: string
siteId: string siteId: string
locale: string
folderPath: string folderPath: string
fileName: string fileName: string
title: string title: string
@ -381,6 +481,10 @@ class Assets {
preview: Buffer | null preview: Buffer | null
authorId: string authorId: string
}): Promise<Asset> { }): Promise<Asset> {
await WIKI.models.storage.putAsset(
{ id, siteId, locale, folderPath, fileName, kind, fileSize: data.length },
data
)
await WIKI.db await WIKI.db
.update(assetsTable) .update(assetsTable)
.set({ .set({
@ -388,7 +492,6 @@ class Assets {
kind, kind,
mimeType, mimeType,
fileSize: data.length, fileSize: data.length,
data,
preview, preview,
authorId, authorId,
updatedAt: sql`now()` updatedAt: sql`now()`
@ -536,27 +639,297 @@ class Assets {
* An asset's bytes, along with what to serve them as. Null if there is no such asset. * An asset's bytes, along with what to serve them as. Null if there is no such asset.
* *
* Not scoped to a site, unlike the rest: the ID is a UUID nobody can guess, and the routes that use * Not scoped to a site, unlike the rest: the ID is a UUID nobody can guess, and the routes that use
* this are the public ones, which have no site of their own to check against. * this are the public ones, which have no site of their own to check against. Where the file sits
* is read off the tree, since that not a record of where it was put is how every target
* addresses its copy.
*/ */
async getContent( async getContent(
id: string id: string
): Promise<{ data: Buffer; mimeType: string; fileName: string } | null> { ): Promise<{ data: Buffer; mimeType: string; fileName: string } | null> {
const results = await WIKI.db const results = await WIKI.db
.select({ .select({
data: assetsTable.data, id: assetsTable.id,
siteId: assetsTable.siteId,
kind: assetsTable.kind,
fileSize: assetsTable.fileSize,
mimeType: assetsTable.mimeType, mimeType: assetsTable.mimeType,
fileName: assetsTable.fileName fileName: assetsTable.fileName,
locale: treeTable.locale,
folderPath: treeTable.folderPath
}) })
.from(assetsTable) .from(assetsTable)
.innerJoin(treeTable, eq(treeTable.id, assetsTable.id))
.where(eq(assetsTable.id, id)) .where(eq(assetsTable.id, id))
.limit(1) .limit(1)
const row = results[0] const row = results[0]
return row?.data ? { data: row.data, mimeType: row.mimeType, fileName: row.fileName } : null if (!row) {
return null
}
const data = await WIKI.models.storage.getAsset({
id: row.id,
siteId: row.siteId,
locale: row.locale,
folderPath: decodeTreePath(row.folderPath ?? '') ?? '',
fileName: row.fileName,
kind: row.kind,
fileSize: row.fileSize ?? 0
})
return data ? { data, mimeType: row.mimeType, fileName: row.fileName } : null
}
// == STORAGE ========================
/**
* Where each of these assets sits, as a storage target addresses one.
*/
async getStorageRefs(siteId: string, ids: string[]): Promise<StorageAssetRef[]> {
if (ids.length < 1) {
return []
}
const rows = await WIKI.db
.select({
id: assetsTable.id,
kind: assetsTable.kind,
fileSize: assetsTable.fileSize,
locale: treeTable.locale,
folderPath: treeTable.folderPath,
fileName: treeTable.fileName
})
.from(assetsTable)
.innerJoin(treeTable, eq(treeTable.id, assetsTable.id))
.where(and(eq(assetsTable.siteId, siteId), inArray(assetsTable.id, ids)))
return rows.map((row) => ({
id: row.id,
siteId,
locale: row.locale,
folderPath: decodeTreePath(row.folderPath ?? '') ?? '',
fileName: row.fileName,
kind: row.kind,
fileSize: row.fileSize ?? 0
}))
}
/**
* Every asset of a site, addressed the way a storage target addresses one.
*
* What a target's export action walks in order to find the files it should be holding and is not.
* Metadata only the bytes of each are fetched one at a time, since the point of moving them off
* the database is that they do not all fit in memory at once.
*
* @param withDatabaseCopy Only the assets whose bytes are in their own row, which is what the
* database target holds. For the offload action, which has nothing to move for the rest.
*/
async listStoredAssets(
siteId: string,
{ withDatabaseCopy }: { withDatabaseCopy?: boolean } = {}
): Promise<StorageAssetRef[]> {
const rows = await WIKI.db
.select({
id: assetsTable.id,
kind: assetsTable.kind,
fileSize: assetsTable.fileSize,
locale: treeTable.locale,
folderPath: treeTable.folderPath,
fileName: treeTable.fileName
})
.from(assetsTable)
.innerJoin(treeTable, eq(treeTable.id, assetsTable.id))
.where(
withDatabaseCopy
? and(eq(assetsTable.siteId, siteId), isNotNull(assetsTable.data))
: eq(assetsTable.siteId, siteId)
)
return rows.map((row) => ({
id: row.id,
siteId,
locale: row.locale,
folderPath: decodeTreePath(row.folderPath ?? '') ?? '',
fileName: row.fileName,
kind: row.kind,
fileSize: row.fileSize ?? 0
}))
}
/**
* Ask the target holding each of these assets to follow where the tree has since put them.
*
* Called after a rename, the tree rows already being correct: `getStorageRefs` reads the
* destination off them, and the caller says where each one came from. Every target holding the
* asset moves its own copy.
*/
async relocateAssets(
siteId: string,
moves: { id: string; previous: { locale: string; folderPath: string; fileName: string } }[]
): Promise<void> {
const refs = await this.getStorageRefs(
siteId,
moves.map((move) => move.id)
)
for (const ref of refs) {
const previous = moves.find((move) => move.id === ref.id)?.previous
if (previous) {
await WIKI.models.storage.relocateAsset(ref, previous)
}
}
}
/**
* Take a file a storage target already holds into the wiki, without writing it anywhere.
*
* The other direction from an upload: the bytes are already in place restored from a backup,
* dropped into the folder by another tool and what is missing is the wiki's record of them. A file
* the wiki has no entry for is adopted where it lies rather than written out again, which is why
* nothing is dispatched to the storage layer for it. Any *other* target configured to hold that kind
* will not have a copy until its own export action runs.
*
* `overwrite` turns the case the wiki DOES have an entry for from a skip into a replacement, for a
* restore where the folder is meant to be the authority. That one is dispatched, and has to be: the
* wiki's copy of those bytes may be what a reader is served from the database target, typically
* so leaving the other targets on the old file would make the import appear to have done nothing.
* There is no history behind an asset, so unlike an overwritten page the bytes it replaces are gone.
*
* Whatever `overwrite` says, only an *asset* is ever replaced. A page or a folder owning the name is
* left alone: a page's own file belongs to the other half of the import, and neither is something a
* loose file in a folder may take over.
*
* @param overwrite Replace an asset already at this path instead of leaving it alone
* @returns The asset, or null for a file this passed over
*/
async adoptStoredFile({
siteId,
locale,
folderPath,
fileName,
data,
authorId,
overwrite
}: {
siteId: string
locale: string
folderPath: string
fileName: string
data: Buffer
authorId: string
overwrite?: boolean
}): Promise<Asset | null> {
const safeName = sanitizeFileName(fileName)
if (!safeName) {
return null
}
// -> Read in full rather than as an existence check, since replacing one needs its ID and the
// name and title it is already filed under
const occupant = await WIKI.models.tree.getEntryAt({
siteId,
locale,
parentPath: folderPath,
fileName: safeName
})
if (occupant && (!overwrite || occupant.type !== 'asset')) {
return null
}
const fileExt = extensionOf(safeName)
try {
await this.guardAgainstPageCollision({
siteId,
locale,
folderPath,
fileName: safeName,
fileExt
})
} catch {
// -> Skipped rather than reported, as everything else this passes over is: the caller is
// walking a folder, and a file that is really a page belongs to the other half of the import
return null
}
const mimeType = mime.getType(safeName) ?? 'application/octet-stream'
const kind = kindOf(mimeType, fileExt)
const preview =
kind === 'image'
? await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height)
: null
if (occupant) {
return this.replace({
id: occupant.id,
siteId,
locale,
folderPath: decodeTreePath(occupant.folderPath ?? '') ?? '',
// -> The names it is already filed under, not the ones off the file: they only differ by
// sanitization, and the entry is the authority on what it is actually called
fileName: occupant.fileName,
title: occupant.title,
fileExt,
kind,
mimeType,
data,
preview,
authorId
})
}
const entry = await WIKI.models.tree.addAsset({
parentPath: folderPath,
fileName: safeName,
title: safeName,
locale,
siteId,
meta: { fileSize: data.length, fileExt, mimeType }
})
try {
await WIKI.db.insert(assetsTable).values({
id: entry.id,
fileName: entry.fileName,
fileExt,
kind,
mimeType,
fileSize: data.length,
preview,
authorId,
siteId
})
} catch (err) {
await WIKI.db.delete(treeTable).where(eq(treeTable.id, entry.id))
throw err
}
const importedFolderPath = decodeTreePath(entry.folderPath ?? '') ?? ''
WIKI.models.hooks.emit('asset:upload', {
id: entry.id,
fileName: entry.fileName,
folderPath: importedFolderPath,
siteId,
authorId,
metadata: { fileSize: data.length, mimeType, kind }
})
return {
id: entry.id,
fileName: entry.fileName,
fileExt,
kind,
mimeType,
fileSize: data.length,
folderPath: importedFolderPath,
title: entry.title,
hasPreview: Boolean(preview),
createdAt: entry.createdAt,
updatedAt: entry.updatedAt
}
} }
/** /**
* An asset's thumbnail, or null when it has none which is the normal state for anything that is * An asset's thumbnail, or null when it has none which is the normal state for anything that is
* not an image, and for images uploaded while Sharp was unavailable. * not an image, and for images uploaded while Sharp was unavailable.
*
* Always read from the database, whichever target holds the file itself. A preview is a few
* kilobytes generated by this model rather than anything a user uploaded, and the file manager asks
* for a screenful of them at a time there is nothing to gain by sending that around a storage
* module, and a target being slow or misconfigured would cost a wiki its whole file browser.
*/ */
async getThumbnail(id: string): Promise<Buffer | null> { async getThumbnail(id: string): Promise<Buffer | null> {
const results = await WIKI.db const results = await WIKI.db
@ -837,6 +1210,18 @@ class Assets {
if (!fileExt) { if (!fileExt) {
throw new CustomError('assetInvalidFileName', 'The file name must keep a file extension.') throw new CustomError('assetInvalidFileName', 'The file name must keep a file extension.')
} }
// -> The same two rules an upload is held to: renaming is another way of arriving at a name, and
// `readme.pdf` renamed to `readme.md` would land on the page of that name just as squarely
const entry = await WIKI.models.tree.getById(id)
if (entry) {
await this.guardAgainstPageCollision({
siteId,
locale: entry.locale,
folderPath: decodeTreePath(entry.folderPath ?? '') ?? '',
fileName: safeName,
fileExt
})
}
const resolvedMime = mime.getType(safeName) ?? asset.mimeType const resolvedMime = mime.getType(safeName) ?? asset.mimeType
await WIKI.models.tree.renameEntry({ id, fileName: safeName, title: safeName }) await WIKI.models.tree.renameEntry({ id, fileName: safeName, title: safeName })
@ -856,6 +1241,21 @@ class Assets {
.set({ meta: { fileSize: asset.fileSize, fileExt, mimeType: resolvedMime } }) .set({ meta: { fileSize: asset.fileSize, fileExt, mimeType: resolvedMime } })
.where(eq(treeTable.id, id)) .where(eq(treeTable.id, id))
// -> Every target holding this asset lays its copy out by path, so each of them has a file to
// move now that the tree rows have been rewritten
if (entry) {
await this.relocateAssets(siteId, [
{
id,
previous: {
locale: entry.locale,
folderPath: asset.folderPath,
fileName: asset.fileName
}
}
])
}
// -> Both ends of the move: the name it left, and the name it took, which something else may have // -> Both ends of the move: the name it left, and the name it took, which something else may have
// been resolved at before it was freed up // been resolved at before it was freed up
this.forgetPath(siteId, asset.folderPath, asset.fileName) this.forgetPath(siteId, asset.folderPath, asset.fileName)
@ -883,8 +1283,14 @@ class Assets {
if (!asset) { if (!asset) {
return false return false
} }
// -> Read before the rows go: where an asset sits is the tree's to say, and the tree row is
// about to be deleted along with it
const [ref] = await this.getStorageRefs(siteId, [id])
await WIKI.db.delete(assetsTable).where(eq(assetsTable.id, id)) await WIKI.db.delete(assetsTable).where(eq(assetsTable.id, id))
await WIKI.models.tree.deleteEntry(id) await WIKI.models.tree.deleteEntry(id)
if (ref) {
await WIKI.models.storage.removeAsset(ref)
}
this.forgetPath(siteId, asset.folderPath, asset.fileName) this.forgetPath(siteId, asset.folderPath, asset.fileName)
await this.dropCachedContent([id]) await this.dropCachedContent([id])
@ -907,8 +1313,37 @@ class Assets {
return return
} }
const ids = entries.map((entry) => entry.id) const ids = entries.map((entry) => entry.id)
/*
Where each asset sat is rebuilt from what the folder deletion reported, not looked up: the tree
rows went with the folder, and they were the only thing that placed these assets. What is still
here is the `assets` row, which is where the kind and size come from a target needs both to
work out whether it was holding the file at all.
*/
const stored = new Map(
(
await WIKI.db
.select({ id: assetsTable.id, kind: assetsTable.kind, fileSize: assetsTable.fileSize })
.from(assetsTable)
.where(inArray(assetsTable.id, ids))
).map((row) => [row.id, row])
)
await WIKI.db.delete(assetsTable).where(inArray(assetsTable.id, ids)) await WIKI.db.delete(assetsTable).where(inArray(assetsTable.id, ids))
for (const entry of entries) {
const row = stored.get(entry.id)
if (row) {
await WIKI.models.storage.removeAsset({
id: entry.id,
siteId,
locale: entry.locale,
folderPath: entry.folderPath,
fileName: entry.fileName,
kind: row.kind,
fileSize: row.fileSize ?? 0
})
}
}
// -> Which paths they sat at is no longer knowable from the tree: those rows went with the folder // -> Which paths they sat at is no longer knowable from the tree: those rows went with the folder
this.forgetAllPaths() this.forgetAllPaths()
await this.dropCachedContent(ids) await this.dropCachedContent(ids)

@ -8,6 +8,7 @@ import {
} from '../helpers/common.ts' } from '../helpers/common.ts'
import type { RenderPermissions, TocNode } from './rendering.ts' import type { RenderPermissions, TocNode } from './rendering.ts'
import type { DeletedEntry } from './tree.ts' import type { DeletedEntry } from './tree.ts'
import type { StoragePageContent, StoragePageRef } from './storage.ts'
/** What each editor produces, which is what the content column holds. */ /** What each editor produces, which is what the content column holds. */
const EDITOR_CONTENT_TYPES: Record<string, string> = { const EDITOR_CONTENT_TYPES: Record<string, string> = {
@ -17,6 +18,45 @@ const EDITOR_CONTENT_TYPES: Record<string, string> = {
redirect: 'redirect' redirect: 'redirect'
} }
/**
* The extension a page's source is written under, by the content type its editor produces.
*
* A page is addressed without one its path is a URL so this only ever appears where a page has to
* be a file: a storage target laying content out by path. It lives here rather than in that module
* because it is a fact about the page, and because it is what decides whether an uploaded file would
* land on top of one. See `storageFileName`.
*/
export const PAGE_FILE_EXTENSIONS: Record<string, string> = {
markdown: 'md',
html: 'html',
asciidoc: 'adoc',
redirect: 'json'
}
/** For a content type added since this was written. */
const DEFAULT_PAGE_FILE_EXTENSION = 'txt'
export function pageFileExtension(contentType: string): string {
return PAGE_FILE_EXTENSIONS[contentType] ?? DEFAULT_PAGE_FILE_EXTENSION
}
/**
* Which editor writes a given file extension.
*
* For a page being imported that did not say which editor it belongs to, which is allowed only where
* the site reserves the extension for pages see `importAll` in the local disk module.
*
* @returns Null when no editor produces that extension, which for a reserved one means the site
* reserved something this wiki has no editor for
*/
export function pageEditorForExtension(ext: string): string | null {
const contentType = Object.entries(PAGE_FILE_EXTENSIONS).find(([, e]) => e === ext)?.[0]
if (!contentType) {
return null
}
return Object.entries(EDITOR_CONTENT_TYPES).find(([, ct]) => ct === contentType)?.[0] ?? null
}
/** /**
* The editor whose pages send their reader somewhere else. * The editor whose pages send their reader somewhere else.
* *
@ -486,6 +526,15 @@ class Pages {
throw new CustomError('pageDuplicatePath', 'A page already exists at this path.', 409) throw new CustomError('pageDuplicatePath', 'A page already exists at this path.', 409)
} }
const pathParts = path.split('/')
await this.guardAgainstAssetCollision({
siteId,
locale,
parentPath: pathParts.slice(0, -1).join('/'),
fileName: pathParts.at(-1)!,
contentType: EDITOR_CONTENT_TYPES[editor] ?? 'text'
})
const alias = await this.validateAlias(siteId, input.alias) const alias = await this.validateAlias(siteId, input.alias)
const { render, toc, text } = await WIKI.models.rendering.postProcess( const { render, toc, text } = await WIKI.models.rendering.postProcess(
siteId, siteId,
@ -496,7 +545,6 @@ class Pages {
} }
) )
const pathParts = path.split('/')
const inserted = await WIKI.db const inserted = await WIKI.db
.insert(pagesTable) .insert(pagesTable)
.values({ .values({
@ -560,6 +608,9 @@ class Pages {
reason: input.reasonForChange reason: input.reasonForChange
}) })
const stored = this.toStoragePage(siteId, page, page.content ?? '')
await WIKI.models.storage.mirrorPage(stored.ref, stored.content)
await WIKI.models.search.indexPage(page.id, locale) await WIKI.models.search.indexPage(page.id, locale)
await WIKI.models.hooks.emit('page:create', { await WIKI.models.hooks.emit('page:create', {
id: page.id, id: page.id,
@ -708,6 +759,11 @@ class Pages {
.where(eq(treeTable.id, id)) .where(eq(treeTable.id, id))
} }
// -> The source is whatever this save set it to, else whatever it already was: a save that only
// changed the title still rewrites the copy, since the title is in its front matter
const stored = this.toStoragePage(siteId, updated, values.content ?? existing.content ?? '')
await WIKI.models.storage.mirrorPage(stored.ref, stored.content)
await WIKI.models.search.indexPage(id, updated.locale) await WIKI.models.search.indexPage(id, updated.locale)
await WIKI.models.hooks.emit('page:edit', { await WIKI.models.hooks.emit('page:edit', {
id, id,
@ -730,10 +786,13 @@ class Pages {
{ path, title }: { path: string; title?: string }, { path, title }: { path: string; title?: string },
actor: PageActor actor: PageActor
): Promise<Page | null> { ): Promise<Page | null> {
const page = await this.getPage({ siteId, id }) // -> With the source, which the move itself does not need: it is what the copy kept by a storage
// target is rewritten from once the page has landed at its new path
const page = await this.getPage({ siteId, id, withContent: true })
if (!page) { if (!page) {
return null return null
} }
const existingContent = page.content
const newPath = normalizePath(path) const newPath = normalizePath(path)
if (newPath === page.path && (title === undefined || title === page.title)) { if (newPath === page.path && (title === undefined || title === page.title)) {
return page return page
@ -755,6 +814,13 @@ class Pages {
if (duplicate.length > 0) { if (duplicate.length > 0) {
throw new CustomError('pageDuplicatePath', 'A page already exists at this path.', 409) throw new CustomError('pageDuplicatePath', 'A page already exists at this path.', 409)
} }
await this.guardAgainstAssetCollision({
siteId,
locale: page.locale,
parentPath: newPath.split('/').slice(0, -1).join('/'),
fileName: newPath.split('/').at(-1)!,
contentType: page.contentType
})
} }
await WIKI.db await WIKI.db
@ -798,6 +864,13 @@ class Pages {
] ]
}) })
// -> Moved and then rewritten, rather than deleted and written afresh: the move is what keeps a
// versioned target's history of the file attached to it, and the rewrite is because a move may
// carry a new title and always carries a new modification time, both of which are in the copy
const stored = this.toStoragePage(siteId, moved, existingContent ?? '')
await WIKI.models.storage.relocatePage(stored.ref, page.path)
await WIKI.models.storage.mirrorPage(stored.ref, stored.content)
await WIKI.models.hooks.emit('page:rename', { await WIKI.models.hooks.emit('page:rename', {
id, id,
path: moved.path, path: moved.path,
@ -832,6 +905,13 @@ class Pages {
// -> A page that overrode the sidebar owns a menu keyed by its own id, which nothing could reach // -> A page that overrode the sidebar owns a menu keyed by its own id, which nothing could reach
// once the page is gone // once the page is gone
await WIKI.models.navigation.deleteNavForEntries([id]) await WIKI.models.navigation.deleteNavForEntries([id])
await WIKI.models.storage.removePage({
id,
siteId,
locale: page.locale,
path: page.path,
contentType: page.contentType
})
await WIKI.models.hooks.emit('page:delete', { await WIKI.models.hooks.emit('page:delete', {
id, id,
@ -867,6 +947,21 @@ class Pages {
authorId: actor.id authorId: actor.id
}) })
} }
// -> Read before the rows go: what a target filed each page under is decided by its content type,
// and guessing would mean reaching for names that may belong to the assets beside them
const contentTypes = new Map(
(
await WIKI.db
.select({ id: pagesTable.id, contentType: pagesTable.contentType })
.from(pagesTable)
.where(
inArray(
pagesTable.id,
entries.map((entry) => entry.id)
)
)
).map((row) => [row.id, row.contentType])
)
await WIKI.db.delete(pagesTable).where( await WIKI.db.delete(pagesTable).where(
inArray( inArray(
pagesTable.id, pagesTable.id,
@ -877,9 +972,20 @@ class Pages {
// -> One per page, as deleting them one at a time would have sent: a subscriber mirroring the // -> One per page, as deleting them one at a time would have sent: a subscriber mirroring the
// wiki has to hear about each page, not about the folder it happened to sit in // wiki has to hear about each page, not about the folder it happened to sit in
for (const entry of entries) { for (const entry of entries) {
const path = entry.folderPath ? `${entry.folderPath}/${entry.fileName}` : entry.fileName
const contentType = contentTypes.get(entry.id)
if (contentType) {
await WIKI.models.storage.removePage({
id: entry.id,
siteId,
locale: entry.locale,
path,
contentType
})
}
await WIKI.models.hooks.emit('page:delete', { await WIKI.models.hooks.emit('page:delete', {
id: entry.id, id: entry.id,
path: entry.folderPath ? `${entry.folderPath}/${entry.fileName}` : entry.fileName, path,
locale: entry.locale, locale: entry.locale,
siteId, siteId,
authorId: actor.id authorId: actor.id
@ -888,6 +994,315 @@ class Pages {
WIKI.logger.debug(`Deleted ${entries.length} page(s) that went with a deleted folder.`) WIKI.logger.debug(`Deleted ${entries.length} page(s) that went with a deleted folder.`)
} }
// == STORAGE ========================
/**
* The file name a page occupies on a target that lays content out by path.
*
* The name a page and an asset can collide on, and so the thing both of them are checked against
* before either is written see `guardAgainstAssetCollision` here and its opposite number in the
* assets model.
*/
storageFileName(fileName: string, contentType: string): string {
return `${fileName}.${pageFileExtension(contentType)}`
}
/**
* The file name an existing page occupies, or null if there is no such page.
*/
async storageFileNameOf(id: string): Promise<string | null> {
const results = await WIKI.db
.select({ path: pagesTable.path, contentType: pagesTable.contentType })
.from(pagesTable)
.where(eq(pagesTable.id, id))
.limit(1)
const row = results[0]
return row ? this.storageFileName(row.path.split('/').at(-1)!, row.contentType) : null
}
/**
* Refuse a page whose stored file would land on an asset that is already there.
*
* The page and the asset have different names as far as the tree is concerned a page is `readme`
* and the asset is `readme.md` so nothing in the tree stops the two coexisting. They only meet
* once the page has to be a file, and by then one of them would be overwriting the other.
*
* Normally unreachable, because the site's `pageExtensions` keeps assets off these extensions in
* the first place. It is the backstop for a site that has removed one from that list, and for
* content that predates its being on it.
*
* @throws `pageNameTakenByAsset` when the name is not free
*/
private async guardAgainstAssetCollision({
siteId,
locale,
parentPath,
fileName,
contentType
}: {
siteId: string
locale: string
parentPath: string
fileName: string
contentType: string
}): Promise<void> {
const storedName = this.storageFileName(fileName, contentType)
const occupant = await WIKI.models.tree.getEntryAt({
siteId,
locale,
parentPath,
fileName: storedName
})
if (occupant?.type === 'asset') {
throw new CustomError(
'pageNameTakenByAsset',
`A file named ${storedName} already exists here, which is where this page would be stored.`,
409
)
}
}
/*
A page always lives in its own row, and none of what follows changes that. What it does is keep a
copy of the page on every storage target configured to hold `pages` the local disk, today
which is a backup of content the database owns rather than a place it has moved to. Nothing here
is ever read back: `getPage` goes to the row, as it always has.
That is why every one of these calls is fired after the database write has succeeded and none of
them is allowed to fail the operation; `WIKI.models.storage` swallows and logs a target that could
not keep up. A wiki whose backup disk filled up is a wiki with a stale backup, not one that has
stopped accepting edits.
*/
/**
* A page in the shape a storage target takes it.
*
* @param content The source, which the caller has to hand: it is not on the `Page` a mutation
* returns unless the read asked for it, and re-reading the row to get it back would cost a query
* per save for something the caller just wrote.
*/
private toStoragePage(
siteId: string,
page: {
id: string
locale: string
path: string
title: string
description?: string | null
editor: string
contentType: string
tags?: string[]
publishState: string
createdAt: Date
updatedAt: Date
},
content: string
): { ref: StoragePageRef; content: StoragePageContent } {
return {
ref: {
id: page.id,
siteId,
locale: page.locale,
path: page.path,
contentType: page.contentType
},
content: {
title: page.title,
description: page.description ?? '',
editor: page.editor,
tags: page.tags ?? [],
// -> A scheduled page is not published yet, whatever its dates say it will be
isPublished: page.publishState === 'published',
createdAt: page.createdAt,
updatedAt: page.updatedAt,
content
}
}
}
/**
* Every page of a site, in the shape a storage target takes them.
*
* What a target's `dump` action walks in order to write copies of content that predates it being
* enabled. Reads the source of every page at once, which is what makes this a maintenance action
* rather than something to call on a request.
*/
async listForStorage(
siteId: string
): Promise<{ ref: StoragePageRef; content: StoragePageContent }[]> {
const rows = await WIKI.db
.select({
id: pagesTable.id,
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
description: pagesTable.description,
editor: pagesTable.editor,
contentType: pagesTable.contentType,
tags: pagesTable.tags,
publishState: pagesTable.publishState,
createdAt: pagesTable.createdAt,
updatedAt: pagesTable.updatedAt,
content: pagesTable.content
})
.from(pagesTable)
.where(eq(pagesTable.siteId, siteId))
return rows.map((row) => this.toStoragePage(siteId, row, row.content ?? ''))
}
/**
* Take a page a storage target holds and the wiki does not into the database.
*
* The direction that makes a target a peer store rather than a write-only backup: pages arrive here
* from a folder restored onto the disk, and once a module exists that can hear about them from
* commits somebody else pushed. What comes back is an ordinary page, because that is the only kind
* there is: it lands in `pages`, gets a tree entry and is served from the row like every other.
*
* A path the wiki already has a page at is left alone rather than overwritten, unless `overwrite`
* says the file is to win. Off, this is the safe direction: the wiki's copy is the one an author has
* been editing, and reconciling a file that changed on both sides is a merge, which is a target's
* business and not this model's. On, the file is taken as the authority for a restore, where what
* is in the folder is what the wiki is supposed to say.
*
* An overwrite is an ordinary save, not a special path: it records a version like any other, so the
* copy it replaced is in the page's history and an administrator who did not mean it can put it
* back. Two things it does not take from the file, both because a save anywhere else in this model
* does not either the page's **editor**, so a `.md` file cannot turn a redirection into markdown
* by landing on it, and its **path**, since that is what identified it in the first place.
*
* @param overwrite Replace a page already at this path instead of leaving it alone
* @returns The imported page, or null when there is already one at that path and `overwrite` is not
* set
*/
async adoptStoredPage({
siteId,
locale,
path,
title,
description,
editor,
tags,
isPublished,
content,
createdAt,
updatedAt,
authorId,
overwrite
}: {
siteId: string
locale: string
path: string
title: string
description?: string
editor: string
tags?: string[]
isPublished?: boolean
content: string
createdAt?: Date
updatedAt?: Date
authorId: string
overwrite?: boolean
}): Promise<Page | null> {
const normalized = normalizePath(path)
const existing = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.locale, locale),
eq(pagesTable.path, normalized)
)
)
.limit(1)
if (existing.length > 0 && !overwrite) {
return null
}
/*
Imported content is rendered with NO script or style permission, whoever ran the import.
What those two allow is a `<script>` or a `<style>` surviving sanitization, and the file this
came out of was not necessarily written by the administrator who pressed the button the whole
point of the feature is content arriving from somewhere else, which for a future git target
means commits from whoever can push. Granting the importer's own privileges to it would turn
"restore my pages" into stored XSS for every reader. An administrator who does want scripts on
an imported page saves it once themselves, deliberately.
*/
const actor = { id: authorId, permissions: [] }
// -> The one difference between the two directions, and deliberately the only one: a page that is
// there is *saved*, through the same method an editor saves through, so it gets a history entry
// and a re-render and a mirrored copy without any of that being reimplemented here
const page = existing[0]
? await this.updatePage(
siteId,
existing[0].id,
{
title,
description,
content,
tags,
publishState: isPublished === false ? 'draft' : 'published',
render: ''
} as Partial<PageInput>,
actor
)
: await this.createPage(
siteId,
{
path: normalized,
locale,
title,
description,
editor,
content,
tags,
publishState: isPublished === false ? 'draft' : 'published',
// -> No stored HTML: the source is all a file carries, and what a reader sees is produced
// from it by the render queue below
render: ''
} as PageInput,
actor
)
// -> Only if the page went away between the two statements above
if (!page) {
return null
}
// -> A restore should not report every page as written today. Applied after the fact because the
// two dates are not something an API client may set, only something a file can carry back.
if (createdAt || updatedAt) {
await WIKI.db
.update(pagesTable)
.set({
...(createdAt ? { createdAt } : {}),
...(updatedAt ? { updatedAt } : {})
})
.where(eq(pagesTable.id, page.id))
// -> Writing the page already put a copy on every target holding pages, stamped with the dates
// it had for the moment it existed with the wrong ones
const restored = this.toStoragePage(
siteId,
{ ...page, createdAt: createdAt ?? page.createdAt, updatedAt: updatedAt ?? page.updatedAt },
content
)
await WIKI.models.storage.mirrorPage(restored.ref, restored.content)
}
// -> An imported page has no HTML until something renders it, which takes a headless browser this
// instance may not have. Best effort: the page is in the wiki either way, and a re-render can
// be asked for from the admin area once one is available.
try {
await this.queueRerender(siteId, page.id, actor)
} catch (err: any) {
WIKI.logger.warn(`Could not queue a render for the imported page ${normalized} [ SKIPPED ]`)
WIKI.logger.warn(err.message)
}
return page
}
/** /**
* Ask for a page to be rendered again from its source, without going through an editor. * Ask for a page to be rendered again from its source, without going through an editor.
* *

@ -193,6 +193,9 @@ class Sites {
}, },
uploads: { uploads: {
conflictBehavior: 'overwrite' conflictBehavior: 'overwrite'
},
storage: {
largeThreshold: '25MB'
} }
}, },
config config
@ -448,6 +451,9 @@ class Sites {
}, },
uploads: { uploads: {
conflictBehavior: 'overwrite' conflictBehavior: 'overwrite'
},
storage: {
largeThreshold: '25MB'
} }
} }
}) })

File diff suppressed because it is too large Load Diff

@ -927,7 +927,56 @@ class Tree {
.where(eq(treeTable.id, folder.id)) .where(eq(treeTable.id, folder.id))
.returning() .returning()
await this.refreshDescendantPaths(folder.siteId, newPath) const movedPages = await this.refreshDescendantPaths(folder.siteId, newPath)
// -> Only moved, never rewritten: none of these pages changed, so the copy a target holds is
// still the right contents at the wrong name
for (const page of movedPages) {
await WIKI.models.storage.relocatePage(
{
id: page.id,
siteId: folder.siteId,
locale: page.locale,
path: page.path,
contentType: page.contentType
},
page.previousPath
)
}
// -> A storage target that lays its content out by path has every one of those files to move.
// Asked for after the rows are correct, so that where each file belongs is read off the tree
// rather than recomputed from the rename.
const movedAssets = await WIKI.db
.select({
id: treeTable.id,
locale: treeTable.locale,
folderPath: treeTable.folderPath,
fileName: treeTable.fileName
})
.from(treeTable)
.where(
and(
eq(treeTable.siteId, folder.siteId),
eq(treeTable.type, 'asset'),
sql`${treeTable.folderPath} <@ ${newPath}::ltree`
)
)
await WIKI.models.assets.relocateAssets(
folder.siteId,
movedAssets.map((row) => ({
id: row.id,
previous: {
locale: row.locale,
// -> Where it was: the same place it is now, with the renamed segment put back
folderPath: (decodeTreePath(row.folderPath ?? '') ?? '').replace(
decodeTreePath(newPath)!,
decodeTreePath(oldPath)!
),
fileName: row.fileName
}
}))
)
// -> Every asset under it is served from a different path now, and nothing about the assets // -> Every asset under it is served from a different path now, and nothing about the assets
// themselves changed for the file cache to notice // themselves changed for the file cache to notice
@ -952,19 +1001,31 @@ class Tree {
* The two hashes are not the same function and neither exists in postgres, so each row is rewritten * The two hashes are not the same function and neither exists in postgres, so each row is rewritten
* from here. What is deliberately not touched is `updatedAt`: the folder moved, the pages under it * from here. What is deliberately not touched is `updatedAt`: the folder moved, the pages under it
* did not change, and marking a few hundred of them as freshly edited would say otherwise. * did not change, and marking a few hundred of them as freshly edited would say otherwise.
*
* @returns Where each page moved from and to, for the copies a storage target keeps of them. The
* old path is only knowable from here a moment later the row no longer says where it was.
*/ */
private async refreshDescendantPaths(siteId: string, path: string): Promise<void> { private async refreshDescendantPaths(
siteId: string,
path: string
): Promise<
{ id: string; locale: string; previousPath: string; path: string; contentType: string }[]
> {
const rows = await WIKI.db const rows = await WIKI.db
.select({ .select({
id: treeTable.id, id: treeTable.id,
type: treeTable.type, type: treeTable.type,
folderPath: treeTable.folderPath, folderPath: treeTable.folderPath,
fileName: treeTable.fileName fileName: treeTable.fileName,
locale: treeTable.locale,
previousPath: pagesTable.path,
contentType: pagesTable.contentType
}) })
.from(treeTable) .from(treeTable)
.leftJoin(pagesTable, eq(pagesTable.id, treeTable.id))
.where(and(eq(treeTable.siteId, siteId), sql`${treeTable.folderPath} <@ ${path}::ltree`)) .where(and(eq(treeTable.siteId, siteId), sql`${treeTable.folderPath} <@ ${path}::ltree`))
let pageCount = 0 const movedPages = []
for (const row of rows) { for (const row of rows) {
const folderPath = decodeTreePath(row.folderPath ?? '') const folderPath = decodeTreePath(row.folderPath ?? '')
const fullPath = folderPath ? `${folderPath}/${row.fileName}` : row.fileName const fullPath = folderPath ? `${folderPath}/${row.fileName}` : row.fileName
@ -977,14 +1038,21 @@ class Tree {
.update(pagesTable) .update(pagesTable)
.set({ path: fullPath, hash: generatePathHash(fullPath) }) .set({ path: fullPath, hash: generatePathHash(fullPath) })
.where(eq(pagesTable.id, row.id)) .where(eq(pagesTable.id, row.id))
pageCount++ movedPages.push({
id: row.id,
locale: row.locale,
previousPath: row.previousPath ?? fullPath,
path: fullPath,
contentType: row.contentType ?? 'markdown'
})
} }
} }
if (rows.length > 0) { if (rows.length > 0) {
WIKI.logger.debug( WIKI.logger.debug(
`Refreshed the path of ${rows.length} moved entrie(s), ${pageCount} of them page(s).` `Refreshed the path of ${rows.length} moved entrie(s), ${movedPages.length} of them page(s).`
) )
} }
return movedPages
} }
/** /**

@ -1,56 +0,0 @@
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

@ -3,8 +3,6 @@ title: 'Database'
icon: '/_assets/icons/ultraviolet-database.svg' icon: '/_assets/icons/ultraviolet-database.svg'
banner: '/_assets/storage/database.jpg' 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.' 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: assetDelivery:
isStreamingSupported: true isStreamingSupported: true
isDirectAccessSupported: false isDirectAccessSupported: false
@ -12,14 +10,10 @@ assetDelivery:
defaultDirectAccessEnabled: false defaultDirectAccessEnabled: false
contentTypes: contentTypes:
defaultTypesEnabled: ['pages', 'images', 'documents', 'others', 'large'] defaultTypesEnabled: ['pages', 'images', 'documents', 'others', 'large']
defaultLargeThreshold: '5MB'
versioning:
isSupported: true
defaultEnabled: false
props: {} props: {}
actions: actions:
purge: offloadUnchecked:
label: Purge All Assets label: Offload Unchecked Content Types
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. hint: For every content type unticked above, move the bytes still held in the database onto the storage targets that are configured to hold it, then clear them from the database. Each file is read back from every destination before its database copy is released, and anything with no other target to go to is left alone. Only the bytes are removed - names, sizes, thumbnails and folders stay in the database, and the files go on being served from wherever they now live.
warn: This is a destructive action! Make sure all asset files are properly stored on another storage module! This action cannot be undone! warn: This releases the database's copy of those files, which for content uploaded before another target was enabled is the only copy there is. Make sure the targets you want them on are enabled and configured for those content types first, and take a database backup before running this.
icon: explosion icon: database-export

@ -0,0 +1,197 @@
import { eq } from 'drizzle-orm'
import { assets as assetsTable } from '../../../db/schema.ts'
import { CONTENT_TYPES } from '../../../models/storage.ts'
import type { StorageModule, StorageTarget } from '../../../models/storage.ts'
/** Byte counts as an administrator reads them, for reporting how much a run gave back. */
function formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`
}
/**
* Database storage module
*
* Holds an asset's bytes in the `data` column of its own row, which is where everything lands until
* a site turns another target on. There is nothing to configure and nothing that can be
* misconfigured: if the wiki is running, this target works.
*
* The bytes sit next to the metadata rather than anywhere addressable, so nothing has to be recorded
* about where they went and a rename moves nothing.
*
* Its one action, `offloadUnchecked`, is the way back out of that: a site that has since enabled
* another target is still carrying every asset uploaded before it, and nothing in the ordinary course
* of things ever moves those.
*/
const dbStorage: StorageModule = {
async putAsset(_target, ref, data) {
await WIKI.db.update(assetsTable).set({ data }).where(eq(assetsTable.id, ref.id))
},
async getAsset(_target, ref) {
const rows = await WIKI.db
.select({ data: assetsTable.data })
.from(assetsTable)
.where(eq(assetsTable.id, ref.id))
.limit(1)
return rows[0]?.data ?? null
},
async deleteAsset(_target, ref) {
// -> Clearing the column rather than deleting the row: this runs when the site stops storing
// that kind of file here as well as when the asset itself is going, and in the latter case
// the row is deleted by the assets model anyway
await WIKI.db.update(assetsTable).set({ data: null }).where(eq(assetsTable.id, ref.id))
},
async moveAsset() {
// -> A row is not addressed by the name of the file it holds
},
/*
The page handlers do nothing, and that is the whole of what this module has to say about pages.
Every other target holding pages is keeping a copy of them; this one is not a copy but the thing
itself. A page's source is a column of its own row, written by the pages model before any of this
is reached, and its `pages` content type is ticked and locked in the admin area to say exactly
that. There is no second write to make here, and a delete takes the row with it.
*/
async putPage() {},
async deletePage() {},
async movePage() {},
/**
* Move the bytes of every content type this target no longer holds onto the targets that do, and
* then let go of them.
*
* The way a site that started out keeping everything in its database stops doing so. Turning
* another target on only affects what is uploaded *from then on*, so untick images here and the
* database is still carrying every image ever uploaded reachable by nothing, since a target is
* only read for a content type it is configured to store. This is what finishes that job: it reads
* each of those assets out of its row, puts it on the targets that are supposed to have it, and only
* then clears the column.
*
* **Only the metadata stays.** The row, its name, its size, its thumbnail and its place in the tree
* are untouched `data` is the one column this empties, and every asset goes on being served from
* wherever it now lives.
*
* Three rules, and the first two are what make it safe to run:
*
* 1. **Nothing is cleared that is not somewhere else first.** The write to each destination is
* read straight back, and a byte count that does not match is a failure for that asset it
* keeps its database copy and the run carries on to the next. This is the only copy of the
* bytes; a target that reports a successful write it did not do must not be taken at its word.
* 2. **An asset with nowhere to go keeps its copy.** A content type unticked here and enabled
* nowhere else has no destination at all, and clearing those rows would simply delete the
* files. They are reported as stranded, and the fix is to enable a target for them.
* 3. **Only unticked types are touched**, so this is how the administrator says what to move: it
* is the Content Types form above that decides, not this action. `pages` can never be among
* them the database is not keeping a copy of a page, it *is* the page.
*
* The bytes are written to every enabled target holding the type rather than only to the one
* nominated for delivery. That nomination is where reads *start*, and a target can only hold it if
* it stores the type anyway but the others are the fallback list behind it, and this is the last
* moment at which they can be brought up to date from a copy known to be current.
*
* Postgres gives the space back on its own schedule: the rows are emptied here, and the file on
* disk shrinks when autovacuum gets to the table.
*/
async offloadUnchecked(target: StorageTarget): Promise<string> {
// -> Whatever this target no longer claims. `pages` is never in it: the column this action
// empties holds assets, and a page's source is a column of its own row that nothing offloads.
const unchecked = CONTENT_TYPES.filter(
(type) => type !== 'pages' && !target.contentTypes.activeTypes.includes(type)
)
if (unchecked.length < 1) {
return 'The database is still configured to hold every content type, so there is nothing to offload. Untick the ones you want moved off it first.'
}
let moved = 0
let freed = 0
let stranded = 0
let failed = 0
for (const ref of await WIKI.models.assets.listStoredAssets(target.siteId, {
withDatabaseCopy: true
})) {
const contentType = WIKI.models.storage.contentTypeFor(target.siteId, ref.kind, ref.fileSize)
if (!unchecked.includes(contentType)) {
continue
}
// -> Every target that is supposed to be holding this asset, which is this one aside from the
// same list an upload of it would be written to today
const destinations = (
await WIKI.models.storage.writeTargetsFor(ref.siteId, ref.kind, ref.fileSize)
).filter((dest) => dest.id !== target.id)
if (destinations.length < 1) {
stranded++
continue
}
const data = await dbStorage.getAsset(target, ref)
if (!data) {
continue
}
try {
for (const dest of destinations) {
const mod = await WIKI.models.storage.ensureModule(dest.module)
if (!mod) {
throw new Error(`the ${dest.title} module has no implementation installed`)
}
await mod.putAsset(dest, ref, data)
// -> Read back rather than trusted. What follows deletes the only copy, so "the write did
// not throw" is not enough of an assurance to delete anything on.
const stored = await mod.getAsset(dest, ref)
if (!stored || stored.length !== data.length) {
throw new Error(`${dest.title} did not have the file back afterwards`)
}
}
} catch (err: any) {
failed++
WIKI.logger.warn(
`Could not offload the asset ${ref.folderPath ? `${ref.folderPath}/` : ''}${ref.fileName} [ SKIPPED ]`
)
WIKI.logger.warn(err.message)
continue
}
await dbStorage.deleteAsset(target, ref)
moved++
freed += data.length
}
WIKI.logger.info(
`Offloaded ${moved} asset(s) totalling ${formatSize(freed)} out of the database [ OK ]`
)
const parts = []
if (moved > 0) {
parts.push(`Offloaded ${moved} asset(s), freeing ${formatSize(freed)} in the database.`)
} else {
parts.push('There was nothing to offload.')
}
if (stranded > 0) {
parts.push(
`${stranded} were left in place: no other enabled target is configured to store them. Enable one and run this again.`
)
}
if (failed > 0) {
parts.push(
`${failed} could not be written to every target and kept their database copy - see the server log.`
)
}
return parts.join(' ')
}
}
export default dbStorage

@ -3,43 +3,32 @@ title: Local File System
icon: '/_assets/icons/ultraviolet-hdd.svg' icon: '/_assets/icons/ultraviolet-hdd.svg'
banner: '/_assets/storage/disk.jpg' 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. 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: assetDelivery:
isStreamingSupported: true isStreamingSupported: true
isDirectAccessSupported: false isDirectAccessSupported: false
defaultStreamingEnabled: true defaultStreamingEnabled: true
defaultDirectAccessEnabled: false defaultDirectAccessEnabled: false
contentTypes: contentTypes:
defaultTypesEnabled: ['images', 'documents', 'others', 'large'] defaultTypesEnabled: ['pages', 'images', 'documents', 'others', 'large']
defaultLargeThreshold: '5MB'
versioning:
isSupported: false
defaultEnabled: false
props: props:
path: path:
type: String type: String
title: Path title: Path
hint: Absolute path without a trailing slash (e.g. /home/wiki/backup, C:\wiki\backup) hint: Where this site's folder tree is written, directly into the path given with no folder for the site inside it - so give each site its own path. Relative paths are resolved from the Wiki.js install directory (e.g. ./data/content, /home/wiki/content, C:\wiki\content)
icon: symlink-directory icon: symlink-directory
order: 1 order: 1
createDailyBackups: default: ./data/content
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: actions:
dump: exportAll:
label: Dump all content to disk label: Export Everything
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. hint: Write a copy of every page and asset this target is configured to hold to the file system, overwriting whatever is already there. Nothing in the database is changed and nothing is moved.
icon: downloads 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: importAll:
label: Import Everything label: Import Everything
hint: Will import all content currently in the local disk folder. hint: Take every page and asset in the folder that the wiki does not have yet into the wiki. A file is imported as a page if its extension is one of the site's Page Extensions, or if it declares an editor in its front matter (or, for JSON, an editor property); everything else is imported as an asset. Anything already at the same path is left alone on both sides, so this is safe to run again and will not pick up a file that was edited on disk.
icon: database-restore
importAllOverwrite:
label: Import Everything and Overwrite
hint: Take every page and asset in the folder, overwriting existing entries. A file is imported as a page if its extension is one of the site's Page Extensions, or if it declares an editor in its front matter (or, for JSON, an editor property); everything else is imported as an asset.
warn: This replaces what the wiki currently has wherever a file in the folder lands on it. An overwritten page keeps its previous version in its history, but a file has none - the bytes it replaces are gone from every storage target holding them.
icon: database-daily-import icon: database-daily-import

@ -0,0 +1,595 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { dump as dumpYaml, load as loadYaml } from 'js-yaml'
import { pageEditorForExtension, pageFileExtension } from '../../../models/pages.ts'
import type {
StorageModule,
StoragePageContent,
StoragePageRef,
StorageTarget
} from '../../../models/storage.ts'
/** Where files go when the target has no path configured, matching the definition's default. */
const DEFAULT_PATH = './data/content'
/** Leading YAML front matter, as `serializePage` writes it. */
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
/** The extension whose pages are written as one JSON document rather than front matter and a body. */
const JSON_EXTENSION = 'json'
/**
* The editor an imported page falls back to.
*
* Only reached for a file the site reserves as a page extension but which no editor writes `txt` on
* a default site. Markdown renders plain prose as prose, so it is the least surprising answer.
*/
const DEFAULT_PAGE_EDITOR = 'markdown'
/**
* What the site's root page is filed as.
*
* A page path may be empty, which is the page a site serves at `/`. It still needs a name of its own
* on disk, and `index` is the one every other tool that writes a tree of documents picks.
*/
const ROOT_PAGE_NAME = 'index'
/**
* Names never picked up by `importAll`.
*
* A half-written file carries the first, and the second is what a Mac leaves in every folder it has
* ever looked at neither is content somebody meant to put in their wiki.
*/
const IGNORED_FILES = /^\.|\.tmp$/
/**
* The root this target writes under, as an absolute path.
*
* A relative setting is resolved from the install directory rather than from the working directory,
* so that `./data/content` means the same folder whichever way the server was started.
*/
function baseDir(target: StorageTarget): string {
return path.resolve(WIKI.ROOTPATH, target.config.path || DEFAULT_PATH)
}
/**
* Where an asset belongs under the root, as a slash-separated relative path.
*
* The locale brackets the tree because the tree repeats itself across locales `guides/logo.png` can
* exist once in each, and all of them would otherwise be the same file.
*
* The site does not, and this is the one thing to know about this layout: a target belongs to exactly
* one site, so the folder an administrator configured IS this site's folder. A level for the site
* inside it would be a folder that never has a sibling, and it would put the tree one step further
* down than the path they typed.
*/
function relPathFor(ref: { locale: string; folderPath: string; fileName: string }): string {
return [ref.locale, ...ref.folderPath.split('/').filter(Boolean), ref.fileName].join('/')
}
/**
* Where a page's copy belongs, alongside the assets of the same folder.
*
* The extension is the one its editor writes, and is exactly what the wiki reserves against uploads
* `models/assets.ts` refuses an attachment that would take this name, so the two never meet here.
*/
function pagePathFor(ref: StoragePageRef): string {
const segments = ref.path.split('/').filter(Boolean)
const fileName = segments.pop() ?? ROOT_PAGE_NAME
return [ref.locale, ...segments, `${fileName}.${pageFileExtension(ref.contentType)}`].join('/')
}
/**
* The absolute path of a stored file, refusing anything that would land outside the root.
*
* Every segment reaching this is either a UUID or a name the tree has already normalized, so this
* catches a stored path that has been tampered with rather than an ordinary mistake but it is the
* only thing between a `..` in the database and the rest of the file system.
*/
function absPathFor(target: StorageTarget, relPath: string): string {
const base = baseDir(target)
const resolved = path.resolve(base, relPath)
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
throw new Error(`The stored path "${relPath}" resolves outside the storage folder.`)
}
return resolved
}
/**
* Remove the folders a deleted file leaves behind, stopping at the first one still in use.
*
* Best effort throughout: a folder that turns out not to be empty, or that another request is
* writing into at that moment, is simply left alone.
*/
async function pruneEmptyDirs(target: StorageTarget, fromDir: string): Promise<void> {
const base = baseDir(target)
let dir = fromDir
while (dir !== base && dir.startsWith(base + path.sep)) {
try {
await fs.rmdir(dir)
} catch {
return
}
dir = path.dirname(dir)
}
}
/**
* Write a file, creating its folder and leaving nothing half-written behind.
*
* Written under a temporary name and renamed, so a reader either finds the previous contents or the
* new ones never the middle of a write. That matters here more than it does for a cache: this is
* the only copy of an asset once the database target has been purged.
*/
async function writeFileAtomic(filePath: string, data: Buffer | string): Promise<void> {
const tempPath = `${filePath}.${process.pid}.tmp`
await fs.mkdir(path.dirname(filePath), { recursive: true })
try {
await fs.writeFile(tempPath, data)
await fs.rename(tempPath, filePath)
} catch (err) {
await fs.rm(tempPath, { force: true }).catch(() => {})
throw err
}
}
/**
* Move a file, coping with a root that spans devices and with the file not being there.
*
* @returns Whether anything was moved
*/
async function moveFile(from: string, to: string): Promise<boolean> {
await fs.mkdir(path.dirname(to), { recursive: true })
try {
await fs.rename(from, to)
return true
} catch (err: any) {
if (err.code === 'ENOENT') {
return false
}
if (err.code !== 'EXDEV') {
throw err
}
// -> `rename` cannot cross a mount point
await fs.copyFile(from, to)
await fs.rm(from, { force: true })
return true
}
}
/** The metadata every page file carries, whichever of the two forms it is written in. */
function pageMeta(page: StoragePageContent): Record<string, any> {
return {
title: page.title,
description: page.description,
published: page.isPublished,
date: page.updatedAt.toISOString(),
tags: page.tags,
// -> The declaration that makes this a page rather than a file that happens to sit here. Nothing
// is imported as a page without it, so it is the one key that must always be written.
editor: page.editor,
dateCreated: page.createdAt.toISOString()
}
}
/**
* A page as a file that stands on its own, in one of two forms.
*
* A **text** page is YAML front matter and then the source as the author wrote it the convention
* every static site generator reads and the one Wiki.js 2.x wrote, so the folder is worth something
* to tools that have never heard of this wiki.
*
* A **JSON** page a redirection today is a single JSON document with the same metadata at its top
* level and the source under `content`. Front matter would leave a `.json` file that is not JSON,
* which is worth avoiding for the one editor whose source is already structured.
*
* Either way the metadata is the point: a bare body says nothing about whether it was published or
* what it was called, and none of that is recoverable from the prose.
*/
function serializePage(ref: StoragePageRef, page: StoragePageContent): string {
if (pageFileExtension(ref.contentType) === JSON_EXTENSION) {
let content: any = page.content
try {
content = JSON.parse(page.content)
} catch {
// -> Kept as the string it is. The column is written by the editor and should always parse,
// and a file that says what it holds beats one this refused to write.
}
return `${JSON.stringify({ ...pageMeta(page), content }, null, 2)}\n`
}
return `---\n${dumpYaml(pageMeta(page))}---\n\n${page.content}\n`
}
/**
* Read a page file back: its declaration, and the source below it.
*
* A page file says it is one by carrying an `editor` in its front matter, or at the top level of
* the JSON document for the one editor written that way. That declaration is what lets a page and an
* attachment share a folder without this module having to guess which is which from an extension,
* and it is how every file this module writes comes back.
*
* Returning null does not settle it. A file whose extension the site reserves for pages is a page
* whatever it does or does not declare see `importAll`, which owns that rule and fills the editor
* in from the extension. This only reports whether the file said so itself.
*
* Beyond the one key it is forgiving: a file may have been hand-written or generated by something
* that has never seen this wiki, so a missing title or date is filled in by the caller, and front
* matter that is not YAML is treated as no declaration rather than as a fault.
*
* @returns The declaration and the source, or null for a file that does not declare itself a page
*/
function deserializePage(
raw: string,
ext: string
): { meta: Record<string, any>; content: string } | null {
if (ext === JSON_EXTENSION) {
let parsed: any
try {
parsed = JSON.parse(raw)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object' || typeof parsed.editor !== 'string') {
return null
}
const { content, ...meta } = parsed
return {
meta,
content: typeof content === 'string' ? content : JSON.stringify(content ?? {})
}
}
const match = FRONT_MATTER.exec(raw)
if (!match) {
return null
}
let meta: Record<string, any>
try {
const parsed = loadYaml(match[1])
if (!parsed || typeof parsed !== 'object') {
return null
}
meta = parsed as Record<string, any>
} catch {
return null
}
if (typeof meta.editor !== 'string' || !meta.editor) {
return null
}
return { meta, content: raw.slice(match[0].length).trim() }
}
/** A front matter date, or undefined for one that is missing or not a date at all. */
function parseDate(value: unknown): Date | undefined {
if (value instanceof Date) {
return value
}
if (typeof value !== 'string') {
return undefined
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date
}
/**
* Take everything in a target's folder into the wiki, either filling in what is missing or letting
* the folder win.
*
* The body of both import actions. They differ by one flag and by the verb they report with, because
* the walk, what counts as a page, and what is done with a file that is neither are the same
* question whichever way a collision is settled see `importAll` for that walk, and the two models'
* `adoptStoredPage` / `adoptStoredFile` for what `overwrite` means once a file has landed on
* something.
*/
async function runImport(
target: StorageTarget,
actorId: string,
{ overwrite }: { overwrite: boolean }
): Promise<string> {
const root = baseDir(target)
let entries
try {
entries = await fs.readdir(root, { recursive: true, withFileTypes: true })
} catch (err: any) {
if (err.code !== 'ENOENT') {
throw err
}
return 'There is nothing in the storage folder for this site yet.'
}
const reserved: string[] = WIKI.sites[target.siteId]?.config?.pageExtensions ?? []
let pages = 0
let assets = 0
let skipped = 0
let failed = 0
for (const entry of entries) {
if (!entry.isFile() || IGNORED_FILES.test(entry.name)) {
continue
}
const filePath = path.join(entry.parentPath, entry.name)
// -> `<locale>/<folders…>/<file>`, so a file sitting straight in the root is outside the
// layout and belongs to no locale
const segments = path.relative(root, filePath).split(path.sep)
if (segments.length < 2) {
continue
}
const [locale, ...rest] = segments
const fileName = rest.pop()!
const ext = path.extname(fileName).replace(/^\./, '').toLowerCase()
const folderPath = rest.join('/')
const raw = await fs.readFile(filePath)
const declared = deserializePage(raw.toString('utf8'), ext)
const isReservedExtension = Boolean(ext) && reserved.includes(ext)
if (!declared && !isReservedExtension) {
const asset = await WIKI.models.assets.adoptStoredFile({
siteId: target.siteId,
locale,
folderPath,
fileName,
data: raw,
authorId: actorId,
overwrite
})
if (asset) {
assets++
} else {
skipped++
}
continue
}
// -> A page is addressed without its extension: `guides/setup.md` is the page at `guides/setup`
const name = fileName.slice(0, fileName.length - (ext ? ext.length + 1 : 0))
const meta = declared?.meta ?? {}
try {
const imported = await WIKI.models.pages.adoptStoredPage({
siteId: target.siteId,
locale,
// -> The root page is filed under a name of its own, and takes the empty path back
path: [...rest, ...(name === ROOT_PAGE_NAME ? [] : [name])].join('/'),
// -> The declaration first, then the file itself: a page written by hand carries no title,
// and its name is the next best thing
title: typeof meta.title === 'string' && meta.title ? meta.title : name,
description: typeof meta.description === 'string' ? meta.description : '',
editor:
typeof meta.editor === 'string' && meta.editor
? meta.editor
: (pageEditorForExtension(ext) ?? DEFAULT_PAGE_EDITOR),
tags: Array.isArray(meta.tags) ? meta.tags.map(String) : [],
isPublished: meta.published !== false,
// -> Undeclared means there was no front matter to strip, so the file is all body
content: declared?.content ?? raw.toString('utf8').trim(),
createdAt: parseDate(meta.dateCreated),
updatedAt: parseDate(meta.date),
authorId: actorId,
overwrite
})
if (imported) {
pages++
} else {
skipped++
}
} catch (err: any) {
// -> One unusable file — an empty body, an editor this wiki does not have, a path it cannot
// address — must not stop the rest of the folder from being imported
failed++
WIKI.logger.warn(`Could not import the page at ${filePath} [ SKIPPED ]`)
WIKI.logger.warn(err.message)
}
}
WIKI.logger.info(`Imported ${pages} page(s) and ${assets} asset(s) from ${root} [ OK ]`)
// -> Nothing is reported as merely imported when a run could have replaced something: an
// administrator reading "Imported 40 pages" has to be able to tell which of the two they ran
const verb = overwrite ? 'Imported or replaced' : 'Imported'
const parts = []
if (pages > 0) {
parts.push(`${verb} ${pages} page(s).`)
}
if (assets > 0) {
parts.push(`${verb} ${assets} asset(s).`)
}
if (parts.length < 1) {
parts.push(overwrite ? 'There was nothing to import.' : 'There was nothing new to import.')
}
if (skipped > 0) {
// -> With `overwrite` the only thing left to skip is a name a page or a folder owns, which is not
// something this action was ever going to take over
parts.push(
overwrite
? `${skipped} could not replace what is at their path and were left alone.`
: `${skipped} were already in the wiki and were left alone.`
)
}
if (failed > 0) {
parts.push(`${failed} could not be imported - see the server log.`)
}
return parts.join(' ')
}
/**
* Local file system storage module
*
* Mirrors the wiki's own tree onto disk under the folder the target is configured with, laid out
* `<locale>/<folders…>/<file>` so that what an administrator sees in the file manager is what they
* find in the folder, and so that a wiki's content remains ordinary files: readable, backed up and
* served by whatever else is on the machine. Pages and assets share that tree, a page filed under its
* editor's extension; keeping the two from colliding belongs to the models, not here.
*
* The folder is the site's own, with no level inside it naming the site see `relPathFor`. Two sites
* therefore must not be pointed at the same path.
*
* Nothing records where a file went. Every path is derived from the ref it is given, the same way
* every time, which is what lets a copy be read back, moved or deleted with nothing stored about
* where it sits and what makes a folder written by one instance mean the same thing to the next.
*
* Where assets and pages differ is in what a failure costs. An **asset** may have no copy anywhere
* else, so writes are atomic and a failure is raised for the caller to fail the upload on. A **page**
* is a database row and always will be, so what sits here is a rendering of it written after the
* fact, never read back, and allowed to fail.
*/
const diskStorage: StorageModule = {
async putAsset(target, ref, data) {
await writeFileAtomic(absPathFor(target, relPathFor(ref)), data)
},
async getAsset(target, ref) {
try {
return await fs.readFile(absPathFor(target, relPathFor(ref)))
} catch (err: any) {
if (err.code !== 'ENOENT') {
throw err
}
// -> This target does not have the file: it was enabled after the asset was uploaded, or the
// folder was emptied from outside the wiki. Not a fault — the caller asks the next target.
return null
}
},
async deleteAsset(target, ref) {
const filePath = absPathFor(target, relPathFor(ref))
await fs.rm(filePath, { force: true })
await pruneEmptyDirs(target, path.dirname(filePath))
},
async moveAsset(target, ref, previous) {
const from = absPathFor(target, relPathFor({ ...ref, ...previous }))
if (await moveFile(from, absPathFor(target, relPathFor(ref)))) {
await pruneEmptyDirs(target, path.dirname(from))
}
},
async putPage(target, ref, page) {
await writeFileAtomic(absPathFor(target, pagePathFor(ref)), serializePage(ref, page))
},
async deletePage(target, ref) {
// -> Exactly one name, taken from the page's own content type. Guessing at the others would mean
// deleting whatever happens to sit beside it: in this folder `readme.html` is as likely to be
// an attachment as it is to be the page `readme`.
const filePath = absPathFor(target, pagePathFor(ref))
await fs.rm(filePath, { force: true })
await pruneEmptyDirs(target, path.dirname(filePath))
},
async movePage(target, ref, previousPath) {
// -> Which editor wrote it does not change when a page moves, so both ends share an extension
const from = absPathFor(target, pagePathFor({ ...ref, path: previousPath }))
if (await moveFile(from, absPathFor(target, pagePathFor(ref)))) {
await pruneEmptyDirs(target, path.dirname(from))
}
},
/**
* Write a copy of everything this target is configured to hold to the file system.
*
* A plain export, and deliberately nothing more: it reads content from wherever it currently lives
* and writes it here, overwriting whatever is already at each path. Nothing in the database is
* touched no asset is repointed at this target, and none of the space they take up elsewhere is
* freed. Run it twice and the second run does the same work to the same effect.
*
* What that makes it useful for is having the folder be a faithful copy of the wiki on demand: a
* backup to archive, a tree to hand to a static site generator, a starting point for another
* instance to import. What it deliberately does not do is migrate: an asset already stored in the
* database goes on being served from the database afterwards, and only content uploaded while this
* target is enabled is stored here in the first place.
*/
async exportAll(target: StorageTarget): Promise<string> {
let assets = 0
let unreadable = 0
for (const asset of await WIKI.models.assets.listStoredAssets(target.siteId)) {
// -> Only what the current configuration says belongs here: an administrator who turned this
// target on for images alone did not ask for their videos to be written out as well
const contentType = WIKI.models.storage.contentTypeFor(
target.siteId,
asset.kind,
asset.fileSize
)
if (!target.contentTypes.activeTypes.includes(contentType)) {
continue
}
const data = await WIKI.models.storage.getAsset(asset)
if (!data) {
unreadable++
continue
}
await diskStorage.putAsset(target, asset, data)
assets++
}
let pages = 0
if (target.contentTypes.activeTypes.includes('pages')) {
for (const { ref, content } of await WIKI.models.pages.listForStorage(target.siteId)) {
await diskStorage.putPage(target, ref, content)
pages++
}
}
WIKI.logger.info(
`Exported ${assets} asset(s) and ${pages} page(s) to ${baseDir(target)} [ OK ]`
)
const parts = []
if (assets > 0 || pages > 0) {
parts.push(`Exported ${pages} page(s) and ${assets} asset(s).`)
} else {
parts.push('There was nothing to export.')
}
if (unreadable > 0) {
parts.push(`${unreadable} asset(s) could not be read and were skipped.`)
}
return parts.join(' ')
},
/**
* Take everything in the folder that the wiki does not know about yet into the wiki.
*
* The direction that makes this folder a store rather than a dumping ground: content arrives here
* from outside restored from a backup, generated by another tool, unpacked from an archive and
* this is what turns it back into pages and assets.
*
* **Two ways a file is a page**, and everything else is an attachment, read where it lies and
* adopted in place rather than copied:
*
* 1. **Its extension is one the site reserves for pages.** Those extensions address a page by URL
* and cannot be uploaded as attachments, so nothing else can be sitting under one which makes
* a hand-written `.md` dropped into the folder a page, as whoever dropped it meant.
* 2. **It declares an editor**, in its front matter or at the top level of its JSON. This is what
* every file written here carries, and it is the only way in for an extension the site does not
* reserve `.adoc` on a default site, where an attachment could just as well be sitting.
*
* Which editor it belongs to comes from that declaration, and only falls back to the extension for
* a reserved one that made none.
*
* A path the wiki already has an entry at is left alone in both directions: nothing on disk is
* overwritten, and nothing in the wiki is. That makes this safe to run repeatedly, and makes it no
* use for picking up a file that changed on both sides reconciling those two is a merge, and this
* module has no history to do one from. A target that does, git being the obvious one, is where
* that belongs. `importAllOverwrite` is the answer for the case where there is nothing to reconcile
* because the folder is simply right.
*/
async importAll(target: StorageTarget, actorId: string): Promise<string> {
return runImport(target, actorId, { overwrite: false })
},
/**
* The same walk, with the folder winning every collision.
*
* For the case `importAll` deliberately refuses: not filling in what the wiki is missing but making
* it say what the folder says a restore onto an instance that already has content, or a tree
* edited outside the wiki that is meant to be taken as the new truth. Nothing else about the import
* changes; only what happens to a file that lands on something.
*
* The two halves are not equally recoverable, which is the thing to know before running it. A
* **page** is replaced by an ordinary save, so its previous version is in its history. An **asset**
* has no history: its bytes are overwritten on every target holding them and the ones they replaced
* are gone.
*/
async importAllOverwrite(target: StorageTarget, actorId: string): Promise<string> {
return runImport(target, actorId, { overwrite: true })
}
}
export default diskStorage

@ -1,65 +0,0 @@
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

@ -1,148 +0,0 @@
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

@ -1,159 +0,0 @@
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

@ -1,94 +0,0 @@
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

@ -47,7 +47,6 @@
"temporal-polyfill": "1.0.2", "temporal-polyfill": "1.0.2",
"twemoji-assets": "https://codeload.github.com/jdecked/twemoji/tar.gz/refs/tags/v17.0.3", "twemoji-assets": "https://codeload.github.com/jdecked/twemoji/tar.gz/refs/tags/v17.0.3",
"uuid": "14.0.1", "uuid": "14.0.1",
"v-network-graph": "0.9.23",
"vue": "3.5.40", "vue": "3.5.40",
"vue-i18n": "11.4.8", "vue-i18n": "11.4.8",
"vue-router": "5.2.0", "vue-router": "5.2.0",
@ -571,12 +570,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dash14/svg-pan-zoom": {
"version": "3.6.9",
"resolved": "https://registry.npmjs.org/@dash14/svg-pan-zoom/-/svg-pan-zoom-3.6.9.tgz",
"integrity": "sha512-6u+KTQec+9+3bRdk2mReix8AGsp2mB40cw0iYfQQzo22QBkeCNpXl2amnfwQzK7xB9oH/62Wvf2z7l6l2w+csA==",
"license": "BSD-2-Clause"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@ -3669,12 +3662,6 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.repeat": { "node_modules/lodash.repeat": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/lodash.repeat/-/lodash.repeat-4.1.0.tgz", "resolved": "https://registry.npmjs.org/lodash.repeat/-/lodash.repeat-4.1.0.tgz",
@ -4674,26 +4661,6 @@
"uuid": "dist-node/bin/uuid" "uuid": "dist-node/bin/uuid"
} }
}, },
"node_modules/v-network-graph": {
"version": "0.9.23",
"resolved": "https://registry.npmjs.org/v-network-graph/-/v-network-graph-0.9.23.tgz",
"integrity": "sha512-DByEHnwjTxgOjpyxEW48PyWTLTYcAxGtzMg6EW1dL5Jntx1OxP07NUzFjPB8IN1Qjerd6mqRrLphJCWubXnHPw==",
"license": "MIT",
"dependencies": {
"@dash14/svg-pan-zoom": "^3.6.9",
"lodash-es": "^4.18.1",
"mitt": "^3.0.1"
},
"peerDependencies": {
"d3-force": "^3.0.0",
"vue": "^3.5.13"
},
"peerDependenciesMeta": {
"d3-force": {
"optional": true
}
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",

@ -56,7 +56,6 @@
"temporal-polyfill": "1.0.2", "temporal-polyfill": "1.0.2",
"twemoji-assets": "https://codeload.github.com/jdecked/twemoji/tar.gz/refs/tags/v17.0.3", "twemoji-assets": "https://codeload.github.com/jdecked/twemoji/tar.gz/refs/tags/v17.0.3",
"uuid": "14.0.1", "uuid": "14.0.1",
"v-network-graph": "0.9.23",
"vue": "3.5.40", "vue": "3.5.40",
"vue-i18n": "11.4.8", "vue-i18n": "11.4.8",
"vue-router": "5.2.0", "vue-router": "5.2.0",

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="wvGVdeqK1wyL1PvDjvN74a" x1="25.193" x2="32.706" y1="19.887" y2="44.278" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0176d0"/><stop offset="1" stop-color="#16538c"/></linearGradient><path fill="url(#wvGVdeqK1wyL1PvDjvN74a)" d="M16,20h24c1.105,0,2,0.895,2,2v14c0,1.105-0.895,2-2,2h-1v6.082 c0,0.553-0.724,0.76-1.016,0.291L34,38H16c-1.105,0-2-0.895-2-2V22C14,20.895,14.895,20,16,20z"/><path fill="#fff" d="M31.587,33h-1.241c-0.216,0-0.407-0.14-0.473-0.346l-0.459-1.434h-2.845l-0.454,1.433 C26.05,32.86,25.858,33,25.642,33h-1.226c-0.176,0-0.299-0.175-0.239-0.341l2.668-7.33c0.072-0.198,0.26-0.329,0.47-0.329h1.434 c0.212,0,0.4,0.133,0.471,0.333l2.606,7.328C31.885,32.826,31.763,33,31.587,33z M29.001,29.837c0,0-0.967-3.13-0.993-3.409h-0.045 c-0.019,0.234-1.01,3.409-1.01,3.409H29.001z"/><path d="M33,25H14v-5h21v3C35,24.105,34.105,25,33,25z" opacity=".05"/><path d="M32.5,24.5H14V20h20.5v2.5C34.5,23.605,33.605,24.5,32.5,24.5z" opacity=".07"/><linearGradient id="wvGVdeqK1wyL1PvDjvN74b" x1="12.177" x2="26.673" y1="3.749" y2="27.335" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#33bef0"/><stop offset="1" stop-color="#22a5e2"/></linearGradient><path fill="url(#wvGVdeqK1wyL1PvDjvN74b)" d="M32,6H8C6.895,6,6,6.895,6,8v14c0,1.105,0.895,2,2,2h1v6.257c0,0.502,0.658,0.691,0.924,0.265 L14,24h18c1.105,0,2-0.895,2-2V8C34,6.895,33.105,6,32,6z"/><path fill="#fff" d="M21.548,18.12c1.143,1.304,2.451,1.146,2.975,1.01c0.109-0.028,0.213,0.055,0.213,0.167v1.176 c0,0.11-0.073,0.205-0.181,0.228c-0.221,0.049-0.608,0.113-1.143,0.113c-1.936,0-3.174-1.901-3.715-2.408 c-1.916,0-3.809-1.52-3.809-4.219c0-3.109,2.075-4.453,4.225-4.453c3.141,0,4.008,2.5,4.008,4.283 C24.121,16.79,22.412,17.844,21.548,18.12z M20.048,11.364c-1.15,0-2.168,0.886-2.168,2.725c0,1.898,1.019,2.701,2.121,2.701 c1.158,0,2.127-0.797,2.127-2.666C22.128,12.202,21.199,11.364,20.048,11.364z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -5,10 +5,9 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts. removing an icon; `check-icons.mjs` fails the build if this drifts.
266 icons. 267 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32}, "la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
"la:arrow-circle-left": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-.719 4.594L8.875 16l6.406 6.406L16.72 21l-4-4H23v-2H12.719l4-4z\"/>","width":32,"height":32}, "la:arrow-circle-left": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-.719 4.594L8.875 16l6.406 6.406L16.72 21l-4-4H23v-2H12.719l4-4z\"/>","width":32,"height":32},
"la:arrow-circle-right": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m.719 4.594L15.28 11l4 4H9v2h10.281l-4 4l1.438 1.406L23.125 16z\"/>","width":32,"height":32}, "la:arrow-circle-right": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m.719 4.594L15.28 11l4 4H9v2h10.281l-4 4l1.438 1.406L23.125 16z\"/>","width":32,"height":32},
@ -221,6 +220,7 @@ export const BUNDLED_ICONS = {
"mdi:format-superscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zM21.85 9h-4.88V8l.89-.82c.76-.64 1.32-1.18 1.7-1.63q.555-.66.57-1.23a.88.88 0 0 0-.27-.7c-.18-.19-.47-.28-.86-.29c-.31.01-.58.07-.84.17l-.66.39l-.45-1.17c.27-.22.59-.39.98-.53S18.85 2 19.32 2c.78 0 1.38.2 1.78.61c.4.39.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.48-.76.93-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24}, "mdi:format-superscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zM21.85 9h-4.88V8l.89-.82c.76-.64 1.32-1.18 1.7-1.63q.555-.66.57-1.23a.88.88 0 0 0-.27-.7c-.18-.19-.47-.28-.86-.29c-.31.01-.58.07-.84.17l-.66.39l-.45-1.17c.27-.22.59-.39.98-.53S18.85 2 19.32 2c.78 0 1.38.2 1.78.61c.4.39.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.48-.76.93-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
"mdi:format-title": {"body":"<path fill=\"currentColor\" d=\"M5 4v3h5.5v12h3V7H19V4z\"/>","width":24,"height":24}, "mdi:format-title": {"body":"<path fill=\"currentColor\" d=\"M5 4v3h5.5v12h3V7H19V4z\"/>","width":24,"height":24},
"mdi:hand-wave-outline": {"body":"<path fill=\"currentColor\" d=\"M7.03 4.95L3.5 8.5c-3.33 3.31-3.33 8.69 0 12s8.69 3.33 12 0l6-6c1-.97 1-2.56 0-3.54c-.1-.12-.23-.23-.37-.32l.37-.39c1-.97 1-2.56 0-3.54c-.14-.16-.33-.3-.5-.41c.38-.92.21-2.02-.54-2.77c-.87-.87-2.22-.96-3.2-.28a2.517 2.517 0 0 0-3.88-.42l-2.51 2.51c-.09-.14-.2-.27-.32-.39a2.53 2.53 0 0 0-3.52 0m1.41 1.42c.2-.2.51-.2.71 0s.2.51 0 .71l-3.18 3.18a3 3 0 0 1 0 4.24l1.41 1.41a5 5 0 0 0 1.12-5.36l6.3-6.3c.2-.2.51-.2.7 0s.21.51 0 .71l-4.59 4.6l1.41 1.41l6.01-6.01c.2-.2.51-.2.71 0s.2.51 0 .71l-6.01 6.01l1.41 1.41l4.95-4.95c.2-.2.51-.2.71 0s.2.51 0 .71l-5.66 5.65l1.41 1.42l3.54-3.54c.2-.2.51-.2.71 0s.2.51 0 .71l-6 6.01c-2.54 2.54-6.65 2.54-9.19 0s-2.54-6.65 0-9.19zM23 17c0 3.31-2.69 6-6 6v-1.5c2.5 0 4.5-2 4.5-4.5zM1 7c0-3.31 2.69-6 6-6v1.5c-2.5 0-4.5 2-4.5 4.5z\"/>","width":24,"height":24}, "mdi:hand-wave-outline": {"body":"<path fill=\"currentColor\" d=\"M7.03 4.95L3.5 8.5c-3.33 3.31-3.33 8.69 0 12s8.69 3.33 12 0l6-6c1-.97 1-2.56 0-3.54c-.1-.12-.23-.23-.37-.32l.37-.39c1-.97 1-2.56 0-3.54c-.14-.16-.33-.3-.5-.41c.38-.92.21-2.02-.54-2.77c-.87-.87-2.22-.96-3.2-.28a2.517 2.517 0 0 0-3.88-.42l-2.51 2.51c-.09-.14-.2-.27-.32-.39a2.53 2.53 0 0 0-3.52 0m1.41 1.42c.2-.2.51-.2.71 0s.2.51 0 .71l-3.18 3.18a3 3 0 0 1 0 4.24l1.41 1.41a5 5 0 0 0 1.12-5.36l6.3-6.3c.2-.2.51-.2.7 0s.21.51 0 .71l-4.59 4.6l1.41 1.41l6.01-6.01c.2-.2.51-.2.71 0s.2.51 0 .71l-6.01 6.01l1.41 1.41l4.95-4.95c.2-.2.51-.2.71 0s.2.51 0 .71l-5.66 5.65l1.41 1.42l3.54-3.54c.2-.2.51-.2.71 0s.2.51 0 .71l-6 6.01c-2.54 2.54-6.65 2.54-9.19 0s-2.54-6.65 0-9.19zM23 17c0 3.31-2.69 6-6 6v-1.5c2.5 0 4.5-2 4.5-4.5zM1 7c0-3.31 2.69-6 6-6v1.5c-2.5 0-4.5 2-4.5 4.5z\"/>","width":24,"height":24},
"mdi:highlight-off": {"body":"<path fill=\"currentColor\" d=\"M12 20c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18C6.47 2 2 6.47 2 12s4.47 10 10 10s10-4.47 10-10S17.53 2 12 2m2.59 6L12 10.59L9.41 8L8 9.41L10.59 12L8 14.59L9.41 16L12 13.41L14.59 16L16 14.59L13.41 12L16 9.41z\"/>","width":24,"height":24},
"mdi:home": {"body":"<path fill=\"currentColor\" d=\"M10 20v-6h4v6h5v-8h3L12 3L2 12h3v8z\"/>","width":24,"height":24}, "mdi:home": {"body":"<path fill=\"currentColor\" d=\"M10 20v-6h4v6h5v-8h3L12 3L2 12h3v8z\"/>","width":24,"height":24},
"mdi:image-plus": {"body":"<path fill=\"currentColor\" d=\"M18 15v3h-3v2h3v3h2v-3h3v-2h-3v-3zm-4.7 6H5c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2v8.3c-.6-.2-1.3-.3-2-.3c-1.1 0-2.2.3-3.1.9L14.5 12L11 16.5l-2.5-3L5 18h8.1c-.1.3-.1.7-.1 1c0 .7.1 1.4.3 2\"/>","width":24,"height":24}, "mdi:image-plus": {"body":"<path fill=\"currentColor\" d=\"M18 15v3h-3v2h3v3h2v-3h3v-2h-3v-3zm-4.7 6H5c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2v8.3c-.6-.2-1.3-.3-2-.3c-1.1 0-2.2.3-3.1.9L14.5 12L11 16.5l-2.5-3L5 18h8.1c-.1.3-.1.7-.1 1c0 .7.1 1.4.3 2\"/>","width":24,"height":24},
"mdi:image-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 .7.13 1.37.35 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v8.35c-.63-.22-1.3-.35-2-.35V5H5v14zm.96-6.71l-2.75 3.54l-1.96-2.36L6.5 17h6.85c.4-1.12 1.12-2.09 2.05-2.79zM20 18v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24}, "mdi:image-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 .7.13 1.37.35 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v8.35c-.63-.22-1.3-.35-2-.35V5H5v14zm.96-6.71l-2.75 3.54l-1.96-2.36L6.5 17h6.85c.4-1.12 1.12-2.09 2.05-2.79zM20 18v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24},
@ -248,6 +248,7 @@ export const BUNDLED_ICONS = {
"mdi:percent-outline": {"body":"<path fill=\"currentColor\" d=\"m18.5 3.5l2 2l-15 15l-2-2zM7 4c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3m10 10c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3M7 6c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m10 10c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":24,"height":24}, "mdi:percent-outline": {"body":"<path fill=\"currentColor\" d=\"m18.5 3.5l2 2l-15 15l-2-2zM7 4c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3m10 10c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3M7 6c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m10 10c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":24,"height":24},
"mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24}, "mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24},
"mdi:playlist-edit": {"body":"<path fill=\"currentColor\" d=\"M3 6v2h11V6zm0 4v2h11v-2zm17 .1c-.1 0-.3.1-.4.2l-1 1l2.1 2.1l1-1c.2-.2.2-.6 0-.8l-1.3-1.3c-.1-.1-.2-.2-.4-.2m-1.9 1.8l-6.1 6V20h2.1l6.1-6.1zM3 14v2h7v-2z\"/>","width":24,"height":24}, "mdi:playlist-edit": {"body":"<path fill=\"currentColor\" d=\"M3 6v2h11V6zm0 4v2h11v-2zm17 .1c-.1 0-.3.1-.4.2l-1 1l2.1 2.1l1-1c.2-.2.2-.6 0-.8l-1.3-1.3c-.1-.1-.2-.2-.4-.2m-1.9 1.8l-6.1 6V20h2.1l6.1-6.1zM3 14v2h7v-2z\"/>","width":24,"height":24},
"mdi:power": {"body":"<path fill=\"currentColor\" d=\"m16.56 5.44l-1.45 1.45A5.97 5.97 0 0 1 18 12a6 6 0 0 1-6 6a6 6 0 0 1-6-6c0-2.17 1.16-4.06 2.88-5.12L7.44 5.44A7.96 7.96 0 0 0 4 12a8 8 0 0 0 8 8a8 8 0 0 0 8-8c0-2.72-1.36-5.12-3.44-6.56M13 3h-2v10h2\"/>","width":24,"height":24},
"mdi:redo-variant": {"body":"<path fill=\"currentColor\" d=\"M10.5 7A6.5 6.5 0 0 0 4 13.5a6.5 6.5 0 0 0 6.5 6.5H14v-2h-3.5C8 18 6 16 6 13.5S8 9 10.5 9h5.67l-3.08 3.09l1.41 1.41L20 8l-5.5-5.5l-1.42 1.41L16.17 7zM18 18h-2v2h2z\"/>","width":24,"height":24}, "mdi:redo-variant": {"body":"<path fill=\"currentColor\" d=\"M10.5 7A6.5 6.5 0 0 0 4 13.5a6.5 6.5 0 0 0 6.5 6.5H14v-2h-3.5C8 18 6 16 6 13.5S8 9 10.5 9h5.67l-3.08 3.09l1.41 1.41L20 8l-5.5-5.5l-1.42 1.41L16.17 7zM18 18h-2v2h2z\"/>","width":24,"height":24},
"mdi:seed-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M17.2 5c.6 0 1.2 0 1.7.1c.14 1.6.18 4.32-.72 6.9c.71 0 1.38.17 2 .41c1.46-4.51.52-9.11.52-9.11S19.3 3 17.2 3c-5.5 0-15.6 2.1-14 17.8c1.1.1 2.2.2 3.2.2c2.35 0 4.34-.31 6-.84c-.24-.62-.4-1.29-.4-1.99c-1.59.55-3.47.83-5.6.83H5.1c-.2-4.6.7-8.2 2.8-10.5C10.4 5.6 14.4 5 17.2 5M17 7C7 7 7 17 7 17C11 9 17 7 17 7m0 10h-3v2h3v3h2v-3h3v-2h-3v-3h-2z\"/>","width":24,"height":24}, "mdi:seed-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M17.2 5c.6 0 1.2 0 1.7.1c.14 1.6.18 4.32-.72 6.9c.71 0 1.38.17 2 .41c1.46-4.51.52-9.11.52-9.11S19.3 3 17.2 3c-5.5 0-15.6 2.1-14 17.8c1.1.1 2.2.2 3.2.2c2.35 0 4.34-.31 6-.84c-.24-.62-.4-1.29-.4-1.99c-1.59.55-3.47.83-5.6.83H5.1c-.2-4.6.7-8.2 2.8-10.5C10.4 5.6 14.4 5 17.2 5M17 7C7 7 7 17 7 17C11 9 17 7 17 7m0 10h-3v2h3v3h2v-3h3v-2h-3v-3h-2z\"/>","width":24,"height":24},
"mdi:star": {"body":"<path fill=\"currentColor\" d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2L9.19 8.62L2 9.24l5.45 4.73L5.82 21z\"/>","width":24,"height":24}, "mdi:star": {"body":"<path fill=\"currentColor\" d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2L9.19 8.62L2 9.24l5.45 4.73L5.82 21z\"/>","width":24,"height":24},

@ -2,7 +2,6 @@ import BlueprintIcon from '@/components/BlueprintIcon.vue'
import StatusLight from '@/components/StatusLight.vue' import StatusLight from '@/components/StatusLight.vue'
import LoadingGeneric from '@/components/LoadingGeneric.vue' import LoadingGeneric from '@/components/LoadingGeneric.vue'
import { registerSharedComponents } from '@/components/shared' import { registerSharedComponents } from '@/components/shared'
import VNetworkGraph from 'v-network-graph'
export function initializeComponents(app) { export function initializeComponents(app) {
app.component('BlueprintIcon', BlueprintIcon) app.component('BlueprintIcon', BlueprintIcon)
@ -10,5 +9,4 @@ export function initializeComponents(app) {
app.component('StatusLight', StatusLight) app.component('StatusLight', StatusLight)
// -> The `w-*` shared library; see components/shared/index.js // -> The `w-*` shared library; see components/shared/index.js
registerSharedComponents(app) registerSharedComponents(app)
app.use(VNetworkGraph)
} }

@ -1,46 +0,0 @@
<template>
<w-dialog v-model="dialogVisible" max-width="550px" persistent @hide="onDialogHide">
<w-card style="min-width: 350px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/ultraviolet-github.svg" size="sm" class="mr-2" />
<span>{{ t(`admin.storage.githubSetupInstallApp`) }}</span>
</w-card-section>
<w-card-section>
<div class="text-body2">{{ t(`admin.storage.githubSetupInstallAppInfo`) }}</div>
<div class="text-body2 mt-4">
<strong class="text-deep-orange">{{
t('admin.storage.githubSetupInstallAppSelect')
}}</strong>
</div>
<div class="text-body2 mt-4">{{ t(`admin.storage.githubSetupInstallAppReturn`) }}</div>
</w-card-section>
<w-card-actions class="card-actions">
<w-space />
<w-btn
unelevated
:label="t(`admin.storage.githubSetupContinue`)"
color="positive"
padding="xs md"
@click="onDialogOK" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK } = useDialogComponent()
// I18N
const { t } = useI18n()
</script>

@ -2,4 +2,3 @@
@use 'animation'; @use 'animation';
@use 'page-contents'; @use 'page-contents';
@import 'v-network-graph/lib/style.css';

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save