mirror of https://github.com/requarks/wiki
parent
d39e063371
commit
91eede058e
File diff suppressed because it is too large
Load Diff
@ -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
|
|
||||||
@ -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
|
||||||
@ -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
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@ -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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue