feat: path prefix for cloud storage + sensitive fields handling

This commit is contained in:
NGPixel committed 2026-08-22 04:23:58 -04:00
1 parent 49555480a7
commit 8ce6b6e62e
24 files changed
+316 -95

No files matched your search

+22 -3
View File
@@ -1,5 +1,7 @@
import { nanoid } from 'nanoid'
import { maskSensitiveProps } from '../helpers/common.ts'
import { limitAuthAttempts } from '../helpers/rateLimit.ts'
import type { AuthStrategy } from '../models/authentication.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
/**
@@ -37,6 +39,23 @@ function loginErrorUrl(redirect: string, code: string): string {
return `/login?${params.toString()}`
}
/**
* A strategy as it may be sent to a client: everything about it, minus the secrets.
*
* A prop the module declares `sensitive` — an OAuth client secret, an LDAP bind password — is
* write-only, and reads as a mask standing in for whatever is stored. The admin area sends the whole
* configuration back when it saves, and `buildConfig` understands the mask as "leave this alone".
*
* Done here rather than in the model because the model's strategies are the ones a login runs on: the
* secret has to stay in the object the module authenticates with, so this is the last point at which
* it can be taken out. `manage:system` on the route is not a reason to skip it — the secret would
* still end up in a browser's memory, its cache and whatever is on the administrator's screen.
*/
function withoutSecrets(strategy: AuthStrategy): AuthStrategy {
const props = WIKI.models.authentication.getModule(strategy.module)?.props ?? {}
return { ...strategy, config: maskSensitiveProps(props, strategy.config) }
}
/**
* Authentication API Routes
*/
@@ -886,7 +905,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'List the configured authentication strategies',
description:
'Instance-wide, i.e. every strategy regardless of which sites offer it. Which of them a given site shows on its login screen, and in what order, is part of that site’s configuration. Configuration values include any secrets a module stores, hence the `manage:system` requirement.',
'Instance-wide, i.e. every strategy regardless of which sites offer it. Which of them a given site shows on its login screen, and in what order, is part of that site’s configuration. A configuration value belonging to a prop marked `sensitive` is write-only and comes back masked, never as the stored secret.',
tags: ['Authentication'],
response: {
200: {
@@ -898,7 +917,7 @@ async function routes(app: FastifyInstance) {
}
},
async () => {
return WIKI.models.authentication.getActiveStrategies()
return (await WIKI.models.authentication.getActiveStrategies()).map(withoutSecrets)
}
)
@@ -934,7 +953,7 @@ async function routes(app: FastifyInstance) {
if (!strategy) {
return reply.notFound('Authentication strategy does not exist.')
}
return strategy
return withoutSecrets(strategy)
}
)
+2 -8
View File
@@ -63,12 +63,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
color: {
type: 'string'
},
vendor: {
type: 'string'
},
website: {
type: 'string'
},
isAvailable: {
type: 'boolean'
},
@@ -132,7 +126,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'object',
additionalProperties: true,
description:
'Values for the module props, completed with the module defaults for any prop that has none stored yet.'
'Values for the module props, completed with the module defaults for any prop that has none stored yet. A prop declared `sensitive` is write-only: where one holds a value it reads as a fixed mask instead, and sending that mask back leaves the stored secret alone.'
}
}
})
@@ -182,7 +176,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'object',
additionalProperties: true,
description:
'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value.'
'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value. A sensitive prop sent back as the mask it was read as keeps its stored value too; send a new value to replace the secret, or an empty string to remove it. On create there is nothing to keep, so the mask leaves the prop unset.'
}
}
})
+2 -8
View File
@@ -36,12 +36,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
banner: {
type: 'string'
},
vendor: {
type: 'string'
},
website: {
type: 'string'
},
contentTypes: {
type: 'object',
description:
@@ -108,7 +102,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'object',
additionalProperties: true,
description:
'Values for the module props, completed with the module defaults for any prop that has none stored yet.'
'Values for the module props, completed with the module defaults for any prop that has none stored yet. A prop declared `sensitive` is write-only: where one holds a value it reads as a fixed mask instead, and sending that mask back leaves the stored secret alone.'
},
actions: {
type: 'array',
@@ -220,7 +214,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'object',
additionalProperties: true,
description:
'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value.'
'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value. A sensitive prop sent back as the mask it was read as keeps its stored value too; send a new value to replace the secret, or an empty string to remove it.'
}
}
})
+14 -2
View File
@@ -1,3 +1,4 @@
import { maskSensitiveProps } from '../helpers/common.ts'
import { STORAGE_DIRECT_ACCESS_FALLBACKS, STORAGE_TARGET_STATUSES } from '../models/storage.ts'
import type { FastifyInstance } from 'fastify'
import type { StorageSiteConfigInput, StorageTargetInput } from '../models/storage.ts'
@@ -18,7 +19,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Get the storage configuration of a site',
description:
'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.',
'The site-wide settings, plus one target per storage module installed in `modules/storage`, whether or not it has ever been enabled. A configuration value belonging to a prop marked `sensitive` is write-only and comes back masked, never as the stored secret. 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'],
params: {
type: 'object',
@@ -82,7 +83,18 @@ async function routes(app: FastifyInstance) {
localePrefix: layout.localePrefix,
syncInterval: `${WIKI.models.storage.syncIntervalFor(req.params.siteId)}m`,
directAccessFallback: WIKI.models.storage.directAccessFallbackFor(req.params.siteId),
targets: await WIKI.models.storage.getSiteTargets(req.params.siteId)
/*
Masked here rather than in the model: the targets the model hands out are the ones the
storage modules read their credentials from, so this is the last point at which a secret
can be taken out without taking it away from the code that needs it.
A prop declared `sensitive` is write-only. The mask comes back with the rest of the
configuration on a save and is understood as "unchanged" — see `buildConfig`.
*/
targets: (await WIKI.models.storage.getSiteTargets(req.params.siteId)).map((target) => ({
...target,
config: maskSensitiveProps(target.props, target.config)
}))
}
}
)
+48
View File
@@ -254,6 +254,54 @@ export interface ModuleProp {
if: unknown[]
}
/**
* What a sensitive prop's value is replaced with on its way to a client.
*
* A prop marked `sensitive` is write-only: an API key or a password an administrator has entered is
* never sent back out, not even to somebody who could read it from the database anyway — a form that
* carries it is a form that leaks it into a browser cache, a proxy log or a screen share. The prop is
* still editable, so something has to occupy the field, and a fixed placeholder is what lets a client
* round-trip the whole configuration back without having to know which fields it was not given.
*
* A value the client sends unchanged therefore means "leave it alone", and `isSensitiveMask` is the
* check every writer has to make. Emptying the field is still how a stored secret is removed, since
* an empty string is not the mask.
*/
export const SENSITIVE_MASK = '••••••••'
/**
* Whether an incoming value is the mask, i.e. a value the client was never given in the first place.
*
* Only for a prop declared sensitive: the mask is an ordinary string, and a prop that is not
* write-only may legitimately be set to it.
*/
export function isSensitiveMask(prop: ModuleProp, value: unknown): boolean {
return prop.sensitive && value === SENSITIVE_MASK
}
/**
* A module's stored config with every sensitive value replaced by the mask.
*
* What a route answers with, rather than what a module is given — the modules read the real values
* out of the same objects, so this has to be the last thing that happens on the way out.
*
* An empty value is left empty rather than masked, because the mask is a statement that something is
* stored: dots over nothing would have an administrator believe a credential is set and hide the
* fact that the target is running on the machine's own identity.
*/
export function maskSensitiveProps(
props: Record<string, ModuleProp>,
config: Record<string, any>
): Record<string, any> {
const masked: Record<string, any> = { ...config }
for (const [key, prop] of Object.entries(props)) {
if (prop.sensitive && typeof masked[key] === 'string' && masked[key].length > 0) {
masked[key] = SENSITIVE_MASK
}
}
return masked
}
export function parseModuleProps(
props: Record<string, ModulePropDeclaration>
): Record<string, ModuleProp> {
+69 -17
View File
@@ -1,6 +1,6 @@
import mime from 'mime'
import { assetRelPath, pageRelPath, serializePage } from './storageFiles.ts'
import type { StorageModule, StorageTarget } from '../models/storage.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.
@@ -10,10 +10,10 @@ import type { StorageModule, StorageTarget } from '../models/storage.ts'
* 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 — `pathPrefixFor` decides what
* brackets it, 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.
* **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.
@@ -45,6 +45,58 @@ export function signingBaseUrl(target: StorageTarget): string | null {
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. */
@@ -93,7 +145,7 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
},
async putAsset(target, ref, data) {
const key = assetRelPath(target, ref)
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
@@ -106,12 +158,12 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
},
async getAsset(target, ref) {
const key = assetRelPath(target, ref)
const key = assetKey(target, ref)
return key ? client.get(target, key) : null
},
async deleteAsset(target, ref) {
const key = assetRelPath(target, ref)
const key = assetKey(target, ref)
if (key) {
await client.remove(target, key)
}
@@ -121,13 +173,13 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
await moveObject(
client,
target,
assetRelPath(target, { ...ref, ...previous }),
assetRelPath(target, ref)
assetKey(target, { ...ref, ...previous }),
assetKey(target, ref)
)
},
async putPage(target, ref, page) {
const key = pageRelPath(target, ref)
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) {
@@ -142,7 +194,7 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
},
async deletePage(target, ref) {
const key = pageRelPath(target, ref)
const key = pageKey(target, ref)
if (key) {
await client.remove(target, key)
}
@@ -152,15 +204,15 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
await moveObject(
client,
target,
pageRelPath(target, { ...ref, path: previousPath }),
pageRelPath(target, ref)
pageKey(target, { ...ref, path: previousPath }),
pageKey(target, ref)
)
},
...(client.presign
? {
async presignAsset(target, ref, options) {
const key = assetRelPath(target, ref)
const key = assetKey(target, ref)
if (!key) {
return null
}
@@ -191,7 +243,7 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
if (!target.contentTypes.activeTypes.includes(contentType)) {
continue
}
if (!assetRelPath(target, asset)) {
if (!assetKey(target, asset)) {
unstored++
continue
}
@@ -207,7 +259,7 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
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)) {
if (!pageKey(target, ref)) {
unstored++
continue
}
+17 -6
View File
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import { load } from 'js-yaml'
import { asc, eq } from 'drizzle-orm'
import { parseModuleProps } from '../helpers/common.ts'
import { isSensitiveMask, parseModuleProps } from '../helpers/common.ts'
import { authentication as authenticationTable, groups as groupsTable } from '../db/schema.ts'
import type { ModuleProp } from '../helpers/common.ts'
import type { SystemIds } from './types.ts'
@@ -15,8 +15,6 @@ export interface AuthModule {
logo?: string
icon?: string
color?: string
vendor?: string
website?: string
isAvailable: boolean
useForm: boolean
usernameType: string
@@ -124,6 +122,10 @@ class Authentication {
*
* Config values are completed from the module's declared defaults, so a prop added to a module
* after a strategy was configured is returned with its default rather than as a missing key.
*
* **These are the real values, client secrets included** — they are what a module is handed when it
* authenticates somebody. A route answering with them owes the client `maskSensitiveProps` first;
* see `api/authentication.ts`.
*/
async getActiveStrategies(): Promise<AuthStrategy[]> {
const strategies = await WIKI.db
@@ -151,6 +153,12 @@ class Authentication {
*
* Read-only props are never taken from the client: they are declarations of something the server
* does not support changing, so the stored value (or the module default) always wins.
*
* A sensitive prop that comes back as the mask is left alone for the same reason — that is the
* value the client was handed in place of the secret, so sending it back says "unchanged" and
* storing it would overwrite a client secret with a row of dots. An empty string is not the mask
* and does clear it. On a *create* there is nothing to keep, so a masked value stays unset, which
* is the right answer for a form that was never given a secret to send.
*/
buildConfig(
moduleKey: string,
@@ -161,7 +169,9 @@ class Authentication {
const config: Record<string, any> = {}
for (const [key, prop] of Object.entries(props)) {
const current = existing[key] !== undefined ? existing[key] : prop.default
config[key] = prop.readOnly || incoming[key] === undefined ? current : incoming[key]
const keep =
prop.readOnly || incoming[key] === undefined || isSensitiveMask(prop, incoming[key])
config[key] = keep ? current : incoming[key]
}
return config
}
@@ -179,8 +189,9 @@ class Authentication {
for (const [key, value] of Object.entries(incoming)) {
const prop = props[key]
// -> Unknown keys are dropped by buildConfig rather than refused: a module losing a prop must
// not make the admin area unable to save
if (!prop || prop.readOnly || value === undefined) {
// not make the admin area unable to save, and the mask is the value a client is given for a
// secret rather than one it is setting — `buildConfig` keeps what is stored for both
if (!prop || prop.readOnly || value === undefined || isSensitiveMask(prop, value)) {
continue
}
if (prop.enum) {
+19 -10
View File
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import { load } from 'js-yaml'
import { and, eq, inArray } from 'drizzle-orm'
import { CustomError, parseModuleProps } from '../helpers/common.ts'
import { CustomError, isSensitiveMask, parseModuleProps } from '../helpers/common.ts'
import { sites as sitesTable, storage as storageTable } from '../db/schema.ts'
import type { ModuleProp } from '../helpers/common.ts'
import type { AssetKind } from './assets.ts'
@@ -164,8 +164,6 @@ export interface StorageDefinition {
description: string
icon: string
banner: string
vendor: string
website: string
contentTypes: {
defaultTypesEnabled: string[]
}
@@ -211,8 +209,6 @@ export interface StorageTarget {
description: string
icon: string
banner: string
vendor: string
website: string
contentTypes: {
activeTypes: string[]
}
@@ -236,6 +232,10 @@ export interface StorageTarget {
* whichever store it is, and the signature is made *for that host* rather than translated onto
* it afterwards — see each module's `presignAsset`, because S3 and GCS sign the host and Azure
* does not. Empty means the store's own address.
*
* It stands in for the bucket, not for whatever the target's `pathPrefix` starts at: the key is
* signed prefix and all, so a domain pointed at the prefix rather than at the root of the bucket
* asks for a path the signature was not made over.
*/
baseUrl: string
/** How long a direct-access URL stays valid, as `5m` or `2h`. See `parseInterval`. */
@@ -661,6 +661,9 @@ class Storage {
*
* Config values are completed from the module's declared defaults, so a prop added to a module
* after a target was configured is returned with its default rather than as a missing key.
*
* **These are the real values, credentials included** — they are what the modules are handed. A
* route answering with them owes the client `maskSensitiveProps` first; see `api/storage.ts`.
*/
async getSiteTargets(siteId: string): Promise<StorageTarget[]> {
const rows = await this.getTargets({ siteId })
@@ -684,8 +687,6 @@ class Storage {
description: definition.description,
icon: definition.icon,
banner: definition.banner,
vendor: definition.vendor,
website: definition.website,
contentTypes: {
activeTypes: contentTypes.activeTypes ?? []
},
@@ -744,6 +745,11 @@ class Storage {
*
* Read-only props are never taken from the client: they are declarations of something the server
* does not support changing, so the stored value (or the module default) always wins.
*
* A sensitive prop that comes back as the mask is left alone for the same reason — that is the
* value the client was handed in place of the secret, so sending it back says "unchanged" and
* storing it would overwrite the credential with a row of dots. An empty string is not the mask
* and does clear it, which is how an administrator moves a target onto the machine's own identity.
*/
buildConfig(
moduleKey: string,
@@ -754,7 +760,9 @@ class Storage {
const config: Record<string, any> = {}
for (const [key, prop] of Object.entries(props)) {
const current = existing[key] !== undefined ? existing[key] : prop.default
config[key] = prop.readOnly || incoming[key] === undefined ? current : incoming[key]
const keep =
prop.readOnly || incoming[key] === undefined || isSensitiveMask(prop, incoming[key])
config[key] = keep ? current : incoming[key]
}
return config
}
@@ -772,8 +780,9 @@ class Storage {
for (const [key, value] of Object.entries(incoming)) {
const prop = props[key]
// -> Unknown keys are dropped by buildConfig rather than refused: a module losing a prop must
// not make the admin area unable to save
if (!prop || prop.readOnly || value === undefined) {
// not make the admin area unable to save, and the mask is the value a client is given for a
// secret rather than one it is setting — `buildConfig` keeps what is stored for both
if (!prop || prop.readOnly || value === undefined || isSensitiveMask(prop, value)) {
continue
}
if (prop.enum) {
@@ -5,8 +5,6 @@ author: requarks.io
logo: https://static.requarks.io/logo/github.svg
icon: /_assets/icons/ultraviolet-github.svg
color: dark-4
vendor: 'GitHub, Inc.'
website: 'https://docs.github.com/en/apps/oauth-apps'
isAvailable: true
useForm: false
usernameType: email
@@ -5,8 +5,6 @@ author: requarks.io
logo: https://static.requarks.io/logo/google.svg
icon: /_assets/icons/ultraviolet-google.svg
color: red-6
vendor: 'Google LLC'
website: 'https://developers.google.com/identity/openid-connect/openid-connect'
isAvailable: true
useForm: false
usernameType: email
@@ -5,8 +5,6 @@ author: requarks.io
logo: https://static.requarks.io/logo/wikijs.svg
icon: /_assets/icons/ultraviolet-data-protection.svg
color: primary
vendor: 'Wiki.js'
website: 'https://js.wiki'
isAvailable: true
useForm: true
usernameType: email
@@ -5,8 +5,6 @@ author: requarks.io
logo: https://static.requarks.io/logo/oidc.svg
icon: /_assets/icons/ultraviolet-openid.svg
color: blue-grey-8
vendor: 'OpenID Foundation'
website: 'https://openid.net/connect/'
isAvailable: true
useForm: false
usernameType: email
@@ -40,27 +38,35 @@ props:
authorizationURL:
type: String
title: Authorization Endpoint URL
hint: Ignored while discovery is on.
hint: Where the browser is sent to log in.
icon: enter
order: 5
if:
- { key: 'useDiscovery', eq: false }
tokenURL:
type: String
title: Token Endpoint URL
hint: Ignored while discovery is on.
hint: Where the authorization code is exchanged for tokens.
icon: exit
order: 6
if:
- { key: 'useDiscovery', eq: false }
userInfoURL:
type: String
title: User Info Endpoint URL
hint: Ignored while discovery is on. Optional even without it — the ID token alone can carry everything needed.
hint: Optional - the ID token alone can carry everything needed.
icon: contact
order: 7
if:
- { key: 'useDiscovery', eq: false }
jwksURL:
type: String
title: JSON Web Key Set URL
hint: Ignored while discovery is on. Where the keys that signed the ID token are published; without it the ID token cannot be verified and logins are refused.
hint: Where the keys that signed the ID token are published. Without it the ID token cannot be verified and logins are refused.
icon: fingerprint-scan
order: 8
if:
- { key: 'useDiscovery', eq: false }
scopes:
type: String
title: Scopes
+8 -1
View File
@@ -30,13 +30,20 @@ props:
hint: The container to store content in. It is created on first use if it does not exist yet.
icon: shipping-container
order: 3
pathPrefix:
type: String
title: Path Prefix
default: ''
hint: Store content under this folder inside the container rather than at its root, e.g. wiki or apps/docs. Leave empty for the root.
icon: symlink-directory
order: 4
accessTier:
type: String
title: Access Tier
default: Cool
hint: What new blobs are stored as. Cool costs less to keep and more to read, which suits content that is served from another target and kept here as a copy.
icon: scan-stock
order: 4
order: 5
enum:
- Hot|Hot
- Cool|Cool
+2 -2
View File
@@ -164,8 +164,8 @@ const azureClient: ObjectStoreClient = {
/**
* Azure Blob Storage module
*
* Blob names are the same paths the disk target writes, so a container and a folder hold the wiki's
* content laid out identically, and the shape of both is the site's `pathPrefixFor` to decide. See
* Blob names are the same paths the disk target writes, under whatever `pathPrefix` this target starts
* at, so a container and a folder hold the wiki's content laid out identically. See
* `helpers/storageObjects.ts` for everything above the four calls below.
*/
export default objectStorageModule(azureClient)
+9 -2
View File
@@ -31,13 +31,20 @@ props:
hint: The bucket to store content in. It must already exist - this target will not create it.
icon: open-box
order: 3
pathPrefix:
type: String
title: Path Prefix
default: ''
hint: Store content under this folder inside the bucket rather than at its root, e.g. wiki or apps/docs. Leave empty for the root.
icon: symlink-directory
order: 4
storageClass:
type: String
title: Storage Class
default: STANDARD
hint: What new objects are stored as. The colder classes cost less to keep and more to read, and charge for a minimum storage duration.
icon: scan-stock
order: 4
order: 5
enum:
- STANDARD|Standard
- NEARLINE|Nearline
@@ -49,7 +56,7 @@ props:
default: ''
hint: Leave empty for Google Cloud Storage itself. Only set this to point at an emulator or a private service endpoint.
icon: api
order: 5
order: 6
actions:
exportAll:
label: Export Everything
+2 -2
View File
@@ -126,8 +126,8 @@ const gcsClient: ObjectStoreClient = {
/**
* Google Cloud Storage module
*
* Object names are the same paths the disk target writes, so a bucket and a folder hold the wiki's
* content laid out identically, and the shape of both is the site's `pathPrefixFor` to decide. See
* Object names are the same paths the disk target writes, under whatever `pathPrefix` this target
* starts at, so a bucket and a folder hold the wiki's content laid out identically. See
* `helpers/storageObjects.ts` for everything above the four calls below.
*/
export default objectStorageModule(gcsClient)
+10 -3
View File
@@ -29,13 +29,20 @@ props:
hint: The bucket to store content in. It must already exist - this target will not create it.
icon: open-box
order: 3
pathPrefix:
type: String
title: Path Prefix
default: ''
hint: Store content under this folder inside the bucket rather than at its root, e.g. wiki or apps/docs. Leave empty for the root.
icon: symlink-directory
order: 4
accessKeyId:
type: String
title: Access Key ID
default: ''
hint: Leave both this and the secret empty to use the credentials the machine already has - an IAM role, or the standard AWS environment variables.
icon: 3d-touch
order: 4
order: 5
secretAccessKey:
type: String
title: Secret Access Key
@@ -43,14 +50,14 @@ props:
hint: The secret for the access key above.
icon: key
sensitive: true
order: 5
order: 6
storageClass:
type: String
title: Storage Class
default: STANDARD
hint: What new objects are stored as. An AWS concept - most compatible stores ignore it, and leaving it at Standard is always safe.
icon: scan-stock
order: 6
order: 7
enum:
- STANDARD|Standard
- STANDARD_IA|Standard Infrequent Access
+2 -2
View File
@@ -201,8 +201,8 @@ const s3Client: ObjectStoreClient = {
* endpoint and a region, and treating that as a preset meant a new module for every service that
* appeared. Empty endpoint is AWS; anything else is whichever store the URL points at.
*
* The keys are the same paths the disk target writes, so a bucket and a folder hold the wiki's content
* laid out identically, and the shape of both is the site's `pathPrefixFor` to decide. See
* The keys are the same paths the disk target writes, under whatever `pathPrefix` this target starts
* at, so a bucket and a folder hold the wiki's content laid out identically. See
* `helpers/storageObjects.ts` for everything above the four calls below.
*/
export default objectStorageModule(s3Client)
@@ -3,8 +3,6 @@ title: SFTP
icon: '/_assets/icons/ultraviolet-nas.svg'
banner: '/_assets/storage/ssh.jpg'
description: Store the wiki's content as ordinary files on a remote server over SSH. The same tree the local disk target writes, on a machine that is not this one. Meant as a copy rather than a source, so it cannot be chosen under Content Delivery.
vendor: 'Wiki.js'
website: 'https://js.wiki'
assetDelivery:
isDirectAccessSupported: false
# -> A place to keep a copy of the site's content, not one to serve it from: every image on every