mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
292 lines
12 KiB
292 lines
12 KiB
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import {
|
|
absPathIn,
|
|
assetRelPath,
|
|
importTree,
|
|
moveStored,
|
|
pageRelPath,
|
|
pruneEmptyDirs,
|
|
resolveRoot,
|
|
serializePage,
|
|
writeFileAtomic
|
|
} from '../../../helpers/storageFiles.ts'
|
|
import type { ImportSummary } from '../../../helpers/storageFiles.ts'
|
|
import type { StorageModule, 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'
|
|
|
|
/** The root this target writes under, as an absolute path. */
|
|
function baseDir(target: StorageTarget): string {
|
|
return resolveRoot(target.config.path, DEFAULT_PATH)
|
|
}
|
|
|
|
/** What an import run did, in the words the two import actions report it with. */
|
|
function describeImport(summary: ImportSummary | null, overwrite: boolean): string {
|
|
if (!summary) {
|
|
return 'There is nothing in the storage folder for this site yet.'
|
|
}
|
|
WIKI.logger.info(`Imported ${summary.pages} page(s) and ${summary.assets} asset(s) [ 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 (summary.pages > 0) {
|
|
parts.push(`${verb} ${summary.pages} page(s).`)
|
|
}
|
|
if (summary.assets > 0) {
|
|
parts.push(`${verb} ${summary.assets} asset(s).`)
|
|
}
|
|
if (parts.length < 1) {
|
|
parts.push(overwrite ? 'There was nothing to import.' : 'There was nothing new to import.')
|
|
}
|
|
if (summary.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
|
|
? `${summary.skipped} could not replace what is at their path and were left alone.`
|
|
: `${summary.skipped} were already in the wiki and were left alone.`
|
|
)
|
|
}
|
|
if (summary.failed > 0) {
|
|
parts.push(`${summary.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>` by default — 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.
|
|
*
|
|
* What brackets that tree is the site's to say and not this module's: the site id, the locale, both or
|
|
* neither, per `pathPrefixFor`. The folder is normally the site's own, since a target belongs to
|
|
* exactly one site — two sites sharing a path is what the site id prefix is for. With the locale
|
|
* prefix off the site stores its primary locale and nothing else, so a file in another locale has no
|
|
* path here at all, and every operation below has to say what it does about that.
|
|
*
|
|
* 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.
|
|
*
|
|
* Everything about the shape of the tree itself — the front matter, what makes a file a page, the
|
|
* walk an import does — is in `helpers/storageFiles.ts`, shared with the git target, which keeps the
|
|
* same tree inside a repository.
|
|
*/
|
|
const diskStorage: StorageModule = {
|
|
canStore(target, ref) {
|
|
return WIKI.models.storage.pathPrefixFor(target.siteId, ref.locale) !== null
|
|
},
|
|
|
|
async putAsset(target, ref, data) {
|
|
const relPath = assetRelPath(target, ref)
|
|
// -> Guarded rather than skipped, unlike every read and delete below: the model asks `canStore`
|
|
// before it dispatches a write, so reaching this means somebody wrote to this target without
|
|
// asking, and dropping the only copy of an asset's bytes is not a thing to do quietly
|
|
if (!relPath) {
|
|
throw new Error(
|
|
`${target.title} has no path for ${ref.locale} content, so ${ref.fileName} cannot be stored there.`
|
|
)
|
|
}
|
|
await writeFileAtomic(absPathIn(baseDir(target), relPath), data)
|
|
},
|
|
|
|
async getAsset(target, ref) {
|
|
const relPath = assetRelPath(target, ref)
|
|
if (!relPath) {
|
|
return null
|
|
}
|
|
try {
|
|
return await fs.readFile(absPathIn(baseDir(target), relPath))
|
|
} 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 relPath = assetRelPath(target, ref)
|
|
if (!relPath) {
|
|
return
|
|
}
|
|
const root = baseDir(target)
|
|
const filePath = absPathIn(root, relPath)
|
|
await fs.rm(filePath, { force: true })
|
|
await pruneEmptyDirs(root, path.dirname(filePath))
|
|
},
|
|
|
|
async moveAsset(target, ref, previous) {
|
|
await moveStored(
|
|
baseDir(target),
|
|
assetRelPath(target, { ...ref, ...previous }),
|
|
assetRelPath(target, ref)
|
|
)
|
|
},
|
|
|
|
async putPage(target, ref, page) {
|
|
const relPath = pageRelPath(target, ref)
|
|
// -> Unlike an asset, a page that has no place here is not a failure worth reporting: it is in
|
|
// the database, which is where a page always is, and this copy is the thing the site declined
|
|
if (!relPath) {
|
|
return
|
|
}
|
|
await writeFileAtomic(absPathIn(baseDir(target), relPath), 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 relPath = pageRelPath(target, ref)
|
|
if (!relPath) {
|
|
return
|
|
}
|
|
const root = baseDir(target)
|
|
const filePath = absPathIn(root, relPath)
|
|
await fs.rm(filePath, { force: true })
|
|
await pruneEmptyDirs(root, path.dirname(filePath))
|
|
},
|
|
|
|
async movePage(target, ref, previous) {
|
|
// -> Which editor wrote it does not change when a page moves, so both ends share an extension
|
|
await moveStored(
|
|
baseDir(target),
|
|
pageRelPath(target, { ...ref, ...previous }),
|
|
pageRelPath(target, ref)
|
|
)
|
|
},
|
|
|
|
/**
|
|
* 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
|
|
let unstored = 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
|
|
}
|
|
// -> And only what the layout has somewhere to put: a site storing its primary locale alone
|
|
// has no path for the rest, and `putAsset` would refuse them one at a time
|
|
if (!assetRelPath(target, asset)) {
|
|
unstored++
|
|
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)) {
|
|
if (!pageRelPath(target, ref)) {
|
|
unstored++
|
|
continue
|
|
}
|
|
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.`)
|
|
}
|
|
if (unstored > 0) {
|
|
const { primaryLocale } = WIKI.models.storage.pathLayoutFor(target.siteId)
|
|
parts.push(
|
|
`${unstored} item(s) are not in the ${primaryLocale} locale, which is the only one this site stores.`
|
|
)
|
|
}
|
|
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. What counts as a page, and what happens to a
|
|
* file that lands on something the wiki already has, are `importTree`'s to say.
|
|
*
|
|
* A path the wiki already has an entry at is left alone in both directions, which makes this safe
|
|
* to run repeatedly and makes it no use for picking up a file that changed on both sides — that is
|
|
* a merge, and this module has no history to do one from. `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 describeImport(
|
|
await importTree({ target, root: baseDir(target), actorId, overwrite: false }),
|
|
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.
|
|
*
|
|
* 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 describeImport(
|
|
await importTree({ target, root: baseDir(target), actorId, overwrite: true }),
|
|
true
|
|
)
|
|
}
|
|
}
|
|
|
|
export default diskStorage
|