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.
321 lines
12 KiB
321 lines
12 KiB
import mime from 'mime'
|
|
import { assetRelPath, pageRelPath, serializePage } from './storageFiles.ts'
|
|
import type { StorageModule, StoragePageRef, StorageTarget } from '../models/storage.ts'
|
|
|
|
/**
|
|
* The shared half of every object-store target — S3, Azure Blob Storage, Google Cloud Storage.
|
|
*
|
|
* All three answer the same four questions (put, get, remove, copy) against a flat namespace of keys,
|
|
* and everything above that is identical between them: which key a page or an asset takes, how a
|
|
* rename is done where there is no rename, what a bulk export walks. That part lives here, so a
|
|
* module is its client and nothing else.
|
|
*
|
|
* **A key is a path**, the same one the disk target would write — the target's own `pathPrefix` and
|
|
* then whatever `pathPrefixFor` brackets the tree with, and pages and assets sit beside each other in
|
|
* it exactly as they do in a folder. An object store has no directories, so the slashes are just
|
|
* characters in a name, which is why there is nothing here about creating or pruning them.
|
|
*
|
|
* Not under `modules/storage/`, for the reason `storageFiles.ts` gives: a directory there without a
|
|
* `definition.yml` takes every storage module down with it.
|
|
*/
|
|
|
|
/**
|
|
* A direct-access URL as the shared layer asks for one.
|
|
*
|
|
* `key` rather than a ref, because signing is about an object and not about the wiki: the store has
|
|
* to know what to declare the response as and whether to make the browser save it, both of which the
|
|
* wiki knows and the object may not have been stored with.
|
|
*/
|
|
export interface PresignRequest {
|
|
key: string
|
|
expiresInSeconds: number
|
|
contentType: string
|
|
/** The file name to save as, when the browser should save rather than display. */
|
|
downloadAs?: string
|
|
}
|
|
|
|
/**
|
|
* The origin a signed URL should be built on, or null for the store's own.
|
|
*
|
|
* Normalized to no trailing slash so that a module can always join it to a key with one, however the
|
|
* administrator typed it.
|
|
*/
|
|
export function signingBaseUrl(target: StorageTarget): string | null {
|
|
const configured = target.assetDelivery.baseUrl?.trim()
|
|
return configured ? configured.replace(/\/+$/, '') : null
|
|
}
|
|
|
|
/**
|
|
* The target's own prefix inside the bucket, as path segments.
|
|
*
|
|
* Empty by default — the wiki's tree starts at the root of the bucket, which is what a bucket made
|
|
* for it should look like. A prefix is what lets one bucket hold this wiki beside something else, or
|
|
* beside another wiki: an object store has no folders to keep two of them apart, so the only thing
|
|
* that can is the keys agreeing to stay on their own side.
|
|
*
|
|
* Normalized rather than rejected. Leading, trailing and doubled slashes all mean the same folder to
|
|
* anybody typing one, and `.` and `..` segments are dropped rather than resolved, because a key is a
|
|
* literal name and neither of them means in a bucket what it means in a path — `a/../b` and `b` are
|
|
* two different objects to every one of these stores.
|
|
*/
|
|
function prefixSegments(target: StorageTarget): string[] {
|
|
return String(target.config.pathPrefix ?? '')
|
|
.split('/')
|
|
.map((segment) => segment.trim())
|
|
.filter((segment) => segment.length > 0 && segment !== '.' && segment !== '..')
|
|
}
|
|
|
|
/**
|
|
* The key an object takes: this target's prefix, then the path the disk target would have written.
|
|
*
|
|
* Two prefixes rather than one because they answer different questions. Where the tree sits *within*
|
|
* a location is the site's answer and the same for every target of it, which is `pathPrefixFor`;
|
|
* which subdirectory of this bucket that tree starts in is this target's alone, the same way the
|
|
* bucket itself is. Object stores only — a path-based target has a configured root to be the
|
|
* equivalent, and the leading segments of a key are the closest a flat namespace comes to one.
|
|
*
|
|
* @returns Null for content this site's layout has no place for, exactly as the relative path does
|
|
*/
|
|
function objectKey(target: StorageTarget, relPath: string | null): string | null {
|
|
if (!relPath) {
|
|
return null
|
|
}
|
|
const prefix = prefixSegments(target)
|
|
return prefix.length > 0 ? `${prefix.join('/')}/${relPath}` : relPath
|
|
}
|
|
|
|
/** Where an asset's object sits in this target's bucket. */
|
|
function assetKey(
|
|
target: StorageTarget,
|
|
ref: { locale: string; folderPath: string; fileName: string }
|
|
): string | null {
|
|
return objectKey(target, assetRelPath(target, ref))
|
|
}
|
|
|
|
/** Where a page's object sits in this target's bucket. */
|
|
function pageKey(target: StorageTarget, ref: StoragePageRef): string | null {
|
|
return objectKey(target, pageRelPath(target, ref))
|
|
}
|
|
|
|
/** What a store has to be able to do for `objectStorageModule` to build a target out of it. */
|
|
export interface ObjectStoreClient {
|
|
/** Write an object, replacing whatever was at that key. */
|
|
put: (target: StorageTarget, key: string, data: Buffer, contentType: string) => Promise<void>
|
|
/** Read one back, or null when the store does not have it. Must not throw for a missing key. */
|
|
get: (target: StorageTarget, key: string) => Promise<Buffer | null>
|
|
/** Drop one. Must not throw for a key that is already gone. */
|
|
remove: (target: StorageTarget, key: string) => Promise<void>
|
|
/**
|
|
* Copy one key onto another, server-side where the store can.
|
|
*
|
|
* @returns Whether there was anything at the source. False rather than a throw, because a target
|
|
* enabled after an upload legitimately has no copy of the file being moved.
|
|
*/
|
|
copy: (target: StorageTarget, fromKey: string, toKey: string) => Promise<boolean>
|
|
/**
|
|
* Sign a URL a reader can fetch the object from without going through the wiki.
|
|
*
|
|
* Optional only in the type: all three object stores implement it, and a store that could not
|
|
* would declare `isDirectAccessSupported: false` and never be asked.
|
|
*/
|
|
presign?: (target: StorageTarget, request: PresignRequest) => Promise<string | null>
|
|
}
|
|
|
|
/**
|
|
* What to declare an object as, so that a store handing it straight to a browser says the right thing.
|
|
*
|
|
* Guessed from the name rather than taken from the asset, because the reference a target is given
|
|
* carries the file's size and kind but not its type — and the name is what the wiki itself resolves
|
|
* the served type from, so guessing the same way keeps the two in step.
|
|
*/
|
|
function contentTypeOf(fileName: string): string {
|
|
return mime.getType(fileName) ?? 'application/octet-stream'
|
|
}
|
|
|
|
/**
|
|
* Turn a client into a storage module.
|
|
*
|
|
* The eight contract methods plus `exportAll`, which is the one action all three declare. A module
|
|
* spreads the result and adds nothing, unless its store can do something the others cannot.
|
|
*/
|
|
export function objectStorageModule(client: ObjectStoreClient): StorageModule {
|
|
const module: StorageModule = {
|
|
canStore(target, ref) {
|
|
return WIKI.models.storage.pathPrefixFor(target.siteId, ref.locale) !== null
|
|
},
|
|
|
|
async putAsset(target, ref, data) {
|
|
const key = assetKey(target, ref)
|
|
// -> Guarded rather than skipped: the model asks `canStore` before dispatching a write, so
|
|
// reaching this means somebody wrote without asking, and an asset's bytes may exist nowhere
|
|
// else
|
|
if (!key) {
|
|
throw new Error(
|
|
`${target.title} has no path for ${ref.locale} content, so ${ref.fileName} cannot be stored there.`
|
|
)
|
|
}
|
|
await client.put(target, key, data, contentTypeOf(ref.fileName))
|
|
},
|
|
|
|
async getAsset(target, ref) {
|
|
const key = assetKey(target, ref)
|
|
return key ? client.get(target, key) : null
|
|
},
|
|
|
|
async deleteAsset(target, ref) {
|
|
const key = assetKey(target, ref)
|
|
if (key) {
|
|
await client.remove(target, key)
|
|
}
|
|
},
|
|
|
|
async moveAsset(target, ref, previous) {
|
|
await moveObject(
|
|
client,
|
|
target,
|
|
assetKey(target, { ...ref, ...previous }),
|
|
assetKey(target, ref)
|
|
)
|
|
},
|
|
|
|
async putPage(target, ref, page) {
|
|
const key = pageKey(target, ref)
|
|
// -> Unlike an asset, a page with no place here is not worth failing over: it is in the
|
|
// database, which is where a page always is, and this copy is the thing the site declined
|
|
if (!key) {
|
|
return
|
|
}
|
|
await client.put(
|
|
target,
|
|
key,
|
|
Buffer.from(serializePage(ref, page), 'utf8'),
|
|
contentTypeOf(key)
|
|
)
|
|
},
|
|
|
|
async deletePage(target, ref) {
|
|
const key = pageKey(target, ref)
|
|
if (key) {
|
|
await client.remove(target, key)
|
|
}
|
|
},
|
|
|
|
async movePage(target, ref, previous) {
|
|
await moveObject(
|
|
client,
|
|
target,
|
|
pageKey(target, { ...ref, ...previous }),
|
|
pageKey(target, ref)
|
|
)
|
|
},
|
|
|
|
...(client.presign
|
|
? {
|
|
async presignAsset(target, ref, options) {
|
|
const key = assetKey(target, ref)
|
|
if (!key) {
|
|
return null
|
|
}
|
|
return client.presign!(target, { key, ...options })
|
|
}
|
|
}
|
|
: {}),
|
|
|
|
/**
|
|
* Write a copy of everything this target is configured to hold into the store.
|
|
*
|
|
* How content that predates the target being enabled gets into it: an upload only ever goes to
|
|
* the targets enabled at the time, so a store turned on today holds nothing from yesterday. A
|
|
* plain copy and nothing more — no database row is touched, nothing is repointed, and running it
|
|
* twice does the same work to the same effect.
|
|
*/
|
|
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)) {
|
|
const contentType = WIKI.models.storage.contentTypeFor(
|
|
target.siteId,
|
|
asset.kind,
|
|
asset.fileSize
|
|
)
|
|
if (!target.contentTypes.activeTypes.includes(contentType)) {
|
|
continue
|
|
}
|
|
if (!assetKey(target, asset)) {
|
|
unstored++
|
|
continue
|
|
}
|
|
const data = await WIKI.models.storage.getAsset(asset)
|
|
if (!data) {
|
|
unreadable++
|
|
continue
|
|
}
|
|
await module.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 (!pageKey(target, ref)) {
|
|
unstored++
|
|
continue
|
|
}
|
|
await module.putPage(target, ref, content)
|
|
pages++
|
|
}
|
|
}
|
|
|
|
WIKI.logger.info(`Exported ${assets} asset(s) and ${pages} page(s) to ${target.title} [ 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(' ')
|
|
}
|
|
}
|
|
|
|
return module
|
|
}
|
|
|
|
/**
|
|
* Follow a rename, which in an object store is a copy and a delete.
|
|
*
|
|
* Either end may be nowhere, as on disk: the layout can have no path for a locale, and a move may
|
|
* cross into or out of it. Moving *into* it has nothing to copy from; moving out of it leaves an
|
|
* object behind at the old key, so that one is a delete.
|
|
*
|
|
* The delete only happens once the copy has reported success, so a store that fails halfway leaves
|
|
* the file at its old key rather than nowhere.
|
|
*/
|
|
async function moveObject(
|
|
client: ObjectStoreClient,
|
|
target: StorageTarget,
|
|
fromKey: string | null,
|
|
toKey: string | null
|
|
): Promise<void> {
|
|
if (!fromKey) {
|
|
return
|
|
}
|
|
if (!toKey) {
|
|
await client.remove(target, fromKey)
|
|
return
|
|
}
|
|
if (await client.copy(target, fromKey, toKey)) {
|
|
await client.remove(target, fromKey)
|
|
}
|
|
}
|