feat: path prefix for cloud storage + sensitive fields handling

scarlett
NGPixel 3 weeks ago
parent 49555480a7
commit 8ce6b6e62e
No known key found for this signature in database

@ -370,6 +370,17 @@ Consequences worth knowing:
failures into `{ ok, error, statusCode, message }` JSON. failures into `{ ok, error, statusCode, message }` JSON.
- **Schema changes**: edit `db/schema.ts`, then `npm run db-generate` and commit the generated - **Schema changes**: edit `db/schema.ts`, then `npm run db-generate` and commit the generated
migration. Never hand-edit an existing migration. migration. Never hand-edit an existing migration.
- **A module prop marked `sensitive` is write-only.** A route answering with a module's stored config
runs it through `maskSensitiveProps` (`helpers/common.ts`) first, which replaces every non-empty
sensitive value with `SENSITIVE_MASK`; the client posts the whole configuration back, and
`isSensitiveMask` is what makes the mask mean "unchanged" rather than a new value. Masking belongs
at the API boundary and nowhere earlier — the config the models hand out is what the modules read
their credentials from. An empty value is never masked, so dots always mean something is stored,
and clearing the field is how a stored secret is removed — except on a create, where there is
nothing to keep and the mask leaves the prop unset. `manage:system` on the route is not a reason to
skip this: the secret still ends up in a browser, a cache and a screen share. Both module-prop
surfaces do it — storage targets (`api/storage.ts`) and authentication strategies
(`withoutSecrets` in `api/authentication.ts`) — so a new one is expected to as well.
- **Dates use the native `Temporal` API**, not luxon (no longer a backend dependency). `Temporal` is a - **Dates use the native `Temporal` API**, not luxon (no longer a backend dependency). `Temporal` is a
global in Node 26 and is typed by the TS 7 lib, so it needs no import. Four things to know: global in Node 26 and is typed by the TS 7 lib, so it needs no import. Four things to know:
- `Temporal.Instant` accepts **exact time units only**`add({ days: 1 })` throws. Since these are - `Temporal.Instant` accepts **exact time units only**`add({ days: 1 })` throws. Since these are
@ -431,8 +442,15 @@ import a tree that was put there from outside.
`ObjectStoreClient` in `helpers/storageObjects.ts` — and `objectStorageModule` builds the whole `ObjectStoreClient` in `helpers/storageObjects.ts` — and `objectStorageModule` builds the whole
`StorageModule` from them. An object key *is* a path, the same one `disk` would write, so a bucket and `StorageModule` from them. An object key *is* a path, the same one `disk` would write, so a bucket and
a folder hold a site's content laid out identically and `pathPrefixFor` decides the shape of both. a folder hold a site's content laid out identically and `pathPrefixFor` decides the shape of both.
Object stores have no rename, so `moveObject` copies and then deletes, in that order, and never Each of the three also takes a **`pathPrefix`**, which is the segments that key starts with — empty by
deletes on a copy that failed. Credentials are optional on all three: left empty, each SDK falls back default, so the tree sits at the root of the bucket, and set when the bucket has to be shared with
something else, since an object store has no folders to keep two tenants apart. It is per target,
unlike everything `pathPrefixFor` answers, for the same reason the bucket name is: *which* store the
tree goes in and *how* the tree is laid out are different questions. It is normalized rather than
validated — surrounding and doubled slashes go, and so do `.` and `..` segments, which name a literal
object in a bucket rather than a relative path. A custom `baseUrl` still stands in for the bucket and
not for the prefix, because the key is signed prefix and all. Object stores have no rename, so
`moveObject` copies and then deletes, in that order, and never deletes on a copy that failed. Credentials are optional on all three: left empty, each SDK falls back
to the machine's own identity (an IAM role, a managed identity, a workload identity), which is how a to the machine's own identity (an IAM role, a managed identity, a workload identity), which is how a
deployment keeps a long-lived secret out of the database. Only `exportAll` is offered — there is no deployment keeps a long-lived secret out of the database. Only `exportAll` is offered — there is no
`importAll`, because nothing but the wiki writes into these buckets, which is exactly what makes git `importAll`, because nothing but the wiki writes into these buckets, which is exactly what makes git

@ -1,5 +1,7 @@
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { maskSensitiveProps } from '../helpers/common.ts'
import { limitAuthAttempts } from '../helpers/rateLimit.ts' import { limitAuthAttempts } from '../helpers/rateLimit.ts'
import type { AuthStrategy } from '../models/authentication.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify' import type { FastifyInstance, FastifyRequest } from 'fastify'
/** /**
@ -37,6 +39,23 @@ function loginErrorUrl(redirect: string, code: string): string {
return `/login?${params.toString()}` 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 * Authentication API Routes
*/ */
@ -886,7 +905,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'List the configured authentication strategies', summary: 'List the configured authentication strategies',
description: 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 sites 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 sites configuration. A configuration value belonging to a prop marked `sensitive` is write-only and comes back masked, never as the stored secret.',
tags: ['Authentication'], tags: ['Authentication'],
response: { response: {
200: { 200: {
@ -898,7 +917,7 @@ async function routes(app: FastifyInstance) {
} }
}, },
async () => { 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) { if (!strategy) {
return reply.notFound('Authentication strategy does not exist.') return reply.notFound('Authentication strategy does not exist.')
} }
return strategy return withoutSecrets(strategy)
} }
) )

@ -63,12 +63,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
color: { color: {
type: 'string' type: 'string'
}, },
vendor: {
type: 'string'
},
website: {
type: 'string'
},
isAvailable: { isAvailable: {
type: 'boolean' type: 'boolean'
}, },
@ -132,7 +126,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'object', type: 'object',
additionalProperties: true, additionalProperties: true,
description: 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', type: 'object',
additionalProperties: true, additionalProperties: true,
description: 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.'
} }
} }
}) })

@ -36,12 +36,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
banner: { banner: {
type: 'string' type: 'string'
}, },
vendor: {
type: 'string'
},
website: {
type: 'string'
},
contentTypes: { contentTypes: {
type: 'object', type: 'object',
description: description:
@ -108,7 +102,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'object', type: 'object',
additionalProperties: true, additionalProperties: true,
description: 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: { actions: {
type: 'array', type: 'array',
@ -220,7 +214,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'object', type: 'object',
additionalProperties: true, additionalProperties: true,
description: 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.'
} }
} }
}) })

@ -1,3 +1,4 @@
import { maskSensitiveProps } from '../helpers/common.ts'
import { STORAGE_DIRECT_ACCESS_FALLBACKS, STORAGE_TARGET_STATUSES } from '../models/storage.ts' import { STORAGE_DIRECT_ACCESS_FALLBACKS, STORAGE_TARGET_STATUSES } from '../models/storage.ts'
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import type { StorageSiteConfigInput, StorageTargetInput } from '../models/storage.ts' import type { StorageSiteConfigInput, StorageTargetInput } from '../models/storage.ts'
@ -18,7 +19,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Get the storage configuration of a site', summary: 'Get the storage configuration of a site',
description: 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'], tags: ['Storage'],
params: { params: {
type: 'object', type: 'object',
@ -82,7 +83,18 @@ async function routes(app: FastifyInstance) {
localePrefix: layout.localePrefix, localePrefix: layout.localePrefix,
syncInterval: `${WIKI.models.storage.syncIntervalFor(req.params.siteId)}m`, syncInterval: `${WIKI.models.storage.syncIntervalFor(req.params.siteId)}m`,
directAccessFallback: WIKI.models.storage.directAccessFallbackFor(req.params.siteId), 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)
}))
} }
} }
) )

@ -254,6 +254,54 @@ export interface ModuleProp {
if: unknown[] 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( export function parseModuleProps(
props: Record<string, ModulePropDeclaration> props: Record<string, ModulePropDeclaration>
): Record<string, ModuleProp> { ): Record<string, ModuleProp> {

@ -1,6 +1,6 @@
import mime from 'mime' import mime from 'mime'
import { assetRelPath, pageRelPath, serializePage } from './storageFiles.ts' 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. * 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 * 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. * module is its client and nothing else.
* *
* **A key is a path**, the same one the disk target would write `pathPrefixFor` decides what * **A key is a path**, the same one the disk target would write the target's own `pathPrefix` and
* brackets it, and pages and assets sit beside each other in it exactly as they do in a folder. An * then whatever `pathPrefixFor` brackets the tree with, and pages and assets sit beside each other in
* object store has no directories, so the slashes are just characters in a name, which is why there is * it exactly as they do in a folder. An object store has no directories, so the slashes are just
* nothing here about creating or pruning them. * 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 * Not under `modules/storage/`, for the reason `storageFiles.ts` gives: a directory there without a
* `definition.yml` takes every storage module down with it. * `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 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. */ /** What a store has to be able to do for `objectStorageModule` to build a target out of it. */
export interface ObjectStoreClient { export interface ObjectStoreClient {
/** Write an object, replacing whatever was at that key. */ /** Write an object, replacing whatever was at that key. */
@ -93,7 +145,7 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
}, },
async putAsset(target, ref, data) { 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 // -> 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 // reaching this means somebody wrote without asking, and an asset's bytes may exist nowhere
// else // else
@ -106,12 +158,12 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
}, },
async getAsset(target, ref) { async getAsset(target, ref) {
const key = assetRelPath(target, ref) const key = assetKey(target, ref)
return key ? client.get(target, key) : null return key ? client.get(target, key) : null
}, },
async deleteAsset(target, ref) { async deleteAsset(target, ref) {
const key = assetRelPath(target, ref) const key = assetKey(target, ref)
if (key) { if (key) {
await client.remove(target, key) await client.remove(target, key)
} }
@ -121,13 +173,13 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
await moveObject( await moveObject(
client, client,
target, target,
assetRelPath(target, { ...ref, ...previous }), assetKey(target, { ...ref, ...previous }),
assetRelPath(target, ref) assetKey(target, ref)
) )
}, },
async putPage(target, ref, page) { 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 // -> 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 // database, which is where a page always is, and this copy is the thing the site declined
if (!key) { if (!key) {
@ -142,7 +194,7 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
}, },
async deletePage(target, ref) { async deletePage(target, ref) {
const key = pageRelPath(target, ref) const key = pageKey(target, ref)
if (key) { if (key) {
await client.remove(target, key) await client.remove(target, key)
} }
@ -152,15 +204,15 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
await moveObject( await moveObject(
client, client,
target, target,
pageRelPath(target, { ...ref, path: previousPath }), pageKey(target, { ...ref, path: previousPath }),
pageRelPath(target, ref) pageKey(target, ref)
) )
}, },
...(client.presign ...(client.presign
? { ? {
async presignAsset(target, ref, options) { async presignAsset(target, ref, options) {
const key = assetRelPath(target, ref) const key = assetKey(target, ref)
if (!key) { if (!key) {
return null return null
} }
@ -191,7 +243,7 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
if (!target.contentTypes.activeTypes.includes(contentType)) { if (!target.contentTypes.activeTypes.includes(contentType)) {
continue continue
} }
if (!assetRelPath(target, asset)) { if (!assetKey(target, asset)) {
unstored++ unstored++
continue continue
} }
@ -207,7 +259,7 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
let pages = 0 let pages = 0
if (target.contentTypes.activeTypes.includes('pages')) { if (target.contentTypes.activeTypes.includes('pages')) {
for (const { ref, content } of await WIKI.models.pages.listForStorage(target.siteId)) { for (const { ref, content } of await WIKI.models.pages.listForStorage(target.siteId)) {
if (!pageRelPath(target, ref)) { if (!pageKey(target, ref)) {
unstored++ unstored++
continue continue
} }

@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { load } from 'js-yaml' import { load } from 'js-yaml'
import { asc, eq } from 'drizzle-orm' 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 { authentication as authenticationTable, groups as groupsTable } from '../db/schema.ts'
import type { ModuleProp } from '../helpers/common.ts' import type { ModuleProp } from '../helpers/common.ts'
import type { SystemIds } from './types.ts' import type { SystemIds } from './types.ts'
@ -15,8 +15,6 @@ export interface AuthModule {
logo?: string logo?: string
icon?: string icon?: string
color?: string color?: string
vendor?: string
website?: string
isAvailable: boolean isAvailable: boolean
useForm: boolean useForm: boolean
usernameType: string 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 * 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. * 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[]> { async getActiveStrategies(): Promise<AuthStrategy[]> {
const strategies = await WIKI.db 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 * 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. * 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( buildConfig(
moduleKey: string, moduleKey: string,
@ -161,7 +169,9 @@ class Authentication {
const config: Record<string, any> = {} const config: Record<string, any> = {}
for (const [key, prop] of Object.entries(props)) { for (const [key, prop] of Object.entries(props)) {
const current = existing[key] !== undefined ? existing[key] : prop.default 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 return config
} }
@ -179,8 +189,9 @@ class Authentication {
for (const [key, value] of Object.entries(incoming)) { for (const [key, value] of Object.entries(incoming)) {
const prop = props[key] const prop = props[key]
// -> Unknown keys are dropped by buildConfig rather than refused: a module losing a prop must // -> Unknown keys are dropped by buildConfig rather than refused: a module losing a prop must
// not make the admin area unable to save // not make the admin area unable to save, and the mask is the value a client is given for a
if (!prop || prop.readOnly || value === undefined) { // secret rather than one it is setting — `buildConfig` keeps what is stored for both
if (!prop || prop.readOnly || value === undefined || isSensitiveMask(prop, value)) {
continue continue
} }
if (prop.enum) { if (prop.enum) {

@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { load } from 'js-yaml' import { load } from 'js-yaml'
import { and, eq, inArray } from 'drizzle-orm' 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 { sites as sitesTable, storage as storageTable } from '../db/schema.ts'
import type { ModuleProp } from '../helpers/common.ts' import type { ModuleProp } from '../helpers/common.ts'
import type { AssetKind } from './assets.ts' import type { AssetKind } from './assets.ts'
@ -164,8 +164,6 @@ export interface StorageDefinition {
description: string description: string
icon: string icon: string
banner: string banner: string
vendor: string
website: string
contentTypes: { contentTypes: {
defaultTypesEnabled: string[] defaultTypesEnabled: string[]
} }
@ -211,8 +209,6 @@ export interface StorageTarget {
description: string description: string
icon: string icon: string
banner: string banner: string
vendor: string
website: string
contentTypes: { contentTypes: {
activeTypes: string[] 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 * 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 * 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. * 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 baseUrl: string
/** How long a direct-access URL stays valid, as `5m` or `2h`. See `parseInterval`. */ /** 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 * 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. * 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[]> { async getSiteTargets(siteId: string): Promise<StorageTarget[]> {
const rows = await this.getTargets({ siteId }) const rows = await this.getTargets({ siteId })
@ -684,8 +687,6 @@ class Storage {
description: definition.description, description: definition.description,
icon: definition.icon, icon: definition.icon,
banner: definition.banner, banner: definition.banner,
vendor: definition.vendor,
website: definition.website,
contentTypes: { contentTypes: {
activeTypes: contentTypes.activeTypes ?? [] 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 * 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. * 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( buildConfig(
moduleKey: string, moduleKey: string,
@ -754,7 +760,9 @@ class Storage {
const config: Record<string, any> = {} const config: Record<string, any> = {}
for (const [key, prop] of Object.entries(props)) { for (const [key, prop] of Object.entries(props)) {
const current = existing[key] !== undefined ? existing[key] : prop.default 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 return config
} }
@ -772,8 +780,9 @@ class Storage {
for (const [key, value] of Object.entries(incoming)) { for (const [key, value] of Object.entries(incoming)) {
const prop = props[key] const prop = props[key]
// -> Unknown keys are dropped by buildConfig rather than refused: a module losing a prop must // -> Unknown keys are dropped by buildConfig rather than refused: a module losing a prop must
// not make the admin area unable to save // not make the admin area unable to save, and the mask is the value a client is given for a
if (!prop || prop.readOnly || value === undefined) { // secret rather than one it is setting — `buildConfig` keeps what is stored for both
if (!prop || prop.readOnly || value === undefined || isSensitiveMask(prop, value)) {
continue continue
} }
if (prop.enum) { if (prop.enum) {

@ -5,8 +5,6 @@ author: requarks.io
logo: https://static.requarks.io/logo/github.svg logo: https://static.requarks.io/logo/github.svg
icon: /_assets/icons/ultraviolet-github.svg icon: /_assets/icons/ultraviolet-github.svg
color: dark-4 color: dark-4
vendor: 'GitHub, Inc.'
website: 'https://docs.github.com/en/apps/oauth-apps'
isAvailable: true isAvailable: true
useForm: false useForm: false
usernameType: email usernameType: email

@ -5,8 +5,6 @@ author: requarks.io
logo: https://static.requarks.io/logo/google.svg logo: https://static.requarks.io/logo/google.svg
icon: /_assets/icons/ultraviolet-google.svg icon: /_assets/icons/ultraviolet-google.svg
color: red-6 color: red-6
vendor: 'Google LLC'
website: 'https://developers.google.com/identity/openid-connect/openid-connect'
isAvailable: true isAvailable: true
useForm: false useForm: false
usernameType: email usernameType: email

@ -5,8 +5,6 @@ author: requarks.io
logo: https://static.requarks.io/logo/wikijs.svg logo: https://static.requarks.io/logo/wikijs.svg
icon: /_assets/icons/ultraviolet-data-protection.svg icon: /_assets/icons/ultraviolet-data-protection.svg
color: primary color: primary
vendor: 'Wiki.js'
website: 'https://js.wiki'
isAvailable: true isAvailable: true
useForm: true useForm: true
usernameType: email usernameType: email

@ -5,8 +5,6 @@ author: requarks.io
logo: https://static.requarks.io/logo/oidc.svg logo: https://static.requarks.io/logo/oidc.svg
icon: /_assets/icons/ultraviolet-openid.svg icon: /_assets/icons/ultraviolet-openid.svg
color: blue-grey-8 color: blue-grey-8
vendor: 'OpenID Foundation'
website: 'https://openid.net/connect/'
isAvailable: true isAvailable: true
useForm: false useForm: false
usernameType: email usernameType: email
@ -40,27 +38,35 @@ props:
authorizationURL: authorizationURL:
type: String type: String
title: Authorization Endpoint URL title: Authorization Endpoint URL
hint: Ignored while discovery is on. hint: Where the browser is sent to log in.
icon: enter icon: enter
order: 5 order: 5
if:
- { key: 'useDiscovery', eq: false }
tokenURL: tokenURL:
type: String type: String
title: Token Endpoint URL title: Token Endpoint URL
hint: Ignored while discovery is on. hint: Where the authorization code is exchanged for tokens.
icon: exit icon: exit
order: 6 order: 6
if:
- { key: 'useDiscovery', eq: false }
userInfoURL: userInfoURL:
type: String type: String
title: User Info Endpoint URL 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 icon: contact
order: 7 order: 7
if:
- { key: 'useDiscovery', eq: false }
jwksURL: jwksURL:
type: String type: String
title: JSON Web Key Set URL 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 icon: fingerprint-scan
order: 8 order: 8
if:
- { key: 'useDiscovery', eq: false }
scopes: scopes:
type: String type: String
title: Scopes title: Scopes

@ -30,13 +30,20 @@ props:
hint: The container to store content in. It is created on first use if it does not exist yet. hint: The container to store content in. It is created on first use if it does not exist yet.
icon: shipping-container icon: shipping-container
order: 3 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: accessTier:
type: String type: String
title: Access Tier title: Access Tier
default: Cool 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. 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 icon: scan-stock
order: 4 order: 5
enum: enum:
- Hot|Hot - Hot|Hot
- Cool|Cool - Cool|Cool

@ -164,8 +164,8 @@ const azureClient: ObjectStoreClient = {
/** /**
* Azure Blob Storage module * Azure Blob Storage module
* *
* Blob names are the same paths the disk target writes, so a container and a folder hold the wiki's * Blob names are the same paths the disk target writes, under whatever `pathPrefix` this target starts
* content laid out identically, and the shape of both is the site's `pathPrefixFor` to decide. See * 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. * `helpers/storageObjects.ts` for everything above the four calls below.
*/ */
export default objectStorageModule(azureClient) export default objectStorageModule(azureClient)

@ -31,13 +31,20 @@ props:
hint: The bucket to store content in. It must already exist - this target will not create it. hint: The bucket to store content in. It must already exist - this target will not create it.
icon: open-box icon: open-box
order: 3 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: storageClass:
type: String type: String
title: Storage Class title: Storage Class
default: STANDARD 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. 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 icon: scan-stock
order: 4 order: 5
enum: enum:
- STANDARD|Standard - STANDARD|Standard
- NEARLINE|Nearline - NEARLINE|Nearline
@ -49,7 +56,7 @@ props:
default: '' default: ''
hint: Leave empty for Google Cloud Storage itself. Only set this to point at an emulator or a private service endpoint. hint: Leave empty for Google Cloud Storage itself. Only set this to point at an emulator or a private service endpoint.
icon: api icon: api
order: 5 order: 6
actions: actions:
exportAll: exportAll:
label: Export Everything label: Export Everything

@ -126,8 +126,8 @@ const gcsClient: ObjectStoreClient = {
/** /**
* Google Cloud Storage module * Google Cloud Storage module
* *
* Object names are the same paths the disk target writes, so a bucket and a folder hold the wiki's * Object names are the same paths the disk target writes, under whatever `pathPrefix` this target
* content laid out identically, and the shape of both is the site's `pathPrefixFor` to decide. See * 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. * `helpers/storageObjects.ts` for everything above the four calls below.
*/ */
export default objectStorageModule(gcsClient) export default objectStorageModule(gcsClient)

@ -29,13 +29,20 @@ props:
hint: The bucket to store content in. It must already exist - this target will not create it. hint: The bucket to store content in. It must already exist - this target will not create it.
icon: open-box icon: open-box
order: 3 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: accessKeyId:
type: String type: String
title: Access Key ID title: Access Key ID
default: '' 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. 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 icon: 3d-touch
order: 4 order: 5
secretAccessKey: secretAccessKey:
type: String type: String
title: Secret Access Key title: Secret Access Key
@ -43,14 +50,14 @@ props:
hint: The secret for the access key above. hint: The secret for the access key above.
icon: key icon: key
sensitive: true sensitive: true
order: 5 order: 6
storageClass: storageClass:
type: String type: String
title: Storage Class title: Storage Class
default: STANDARD 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. 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 icon: scan-stock
order: 6 order: 7
enum: enum:
- STANDARD|Standard - STANDARD|Standard
- STANDARD_IA|Standard Infrequent Access - STANDARD_IA|Standard Infrequent Access

@ -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 * 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. * 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 * The keys are the same paths the disk target writes, under whatever `pathPrefix` this target starts
* laid out identically, and the shape of both is the site's `pathPrefixFor` to decide. See * 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. * `helpers/storageObjects.ts` for everything above the four calls below.
*/ */
export default objectStorageModule(s3Client) export default objectStorageModule(s3Client)

@ -3,8 +3,6 @@ title: SFTP
icon: '/_assets/icons/ultraviolet-nas.svg' icon: '/_assets/icons/ultraviolet-nas.svg'
banner: '/_assets/storage/ssh.jpg' 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. 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: assetDelivery:
isDirectAccessSupported: false isDirectAccessSupported: false
# -> A place to keep a copy of the site's content, not one to serve it from: every image on every # -> A place to keep a copy of the site's content, not one to serve it from: every image on every

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts. removing an icon; `check-icons.mjs` fails the build if this drifts.
267 icons. 266 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32}, "la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -157,7 +157,6 @@ export const BUNDLED_ICONS = {
"mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24}, "mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24},
"mdi:bell": {"body":"<path fill=\"currentColor\" d=\"M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2\"/>","width":24,"height":24}, "mdi:bell": {"body":"<path fill=\"currentColor\" d=\"M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2\"/>","width":24,"height":24},
"mdi:bell-off-outline": {"body":"<path fill=\"currentColor\" d=\"M22.11 21.46L2.39 1.73L1.11 3l4.72 4.72A7 7 0 0 0 5 11v6l-2 2v1h15.11l2.73 2.73zM7 18v-7c0-.61.11-1.21.34-1.77L16.11 18zm3 3h4a2 2 0 0 1-2 2a2 2 0 0 1-2-2M8.29 5.09c.53-.34 1.11-.59 1.71-.8V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v4.8l-2-2V11a5 5 0 0 0-5-5c-.78 0-1.55.2-2.24.56z\"/>","width":24,"height":24}, "mdi:bell-off-outline": {"body":"<path fill=\"currentColor\" d=\"M22.11 21.46L2.39 1.73L1.11 3l4.72 4.72A7 7 0 0 0 5 11v6l-2 2v1h15.11l2.73 2.73zM7 18v-7c0-.61.11-1.21.34-1.77L16.11 18zm3 3h4a2 2 0 0 1-2 2a2 2 0 0 1-2-2M8.29 5.09c.53-.34 1.11-.59 1.71-.8V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v4.8l-2-2V11a5 5 0 0 0-5-5c-.78 0-1.55.2-2.24.56z\"/>","width":24,"height":24},
"mdi:bell-outline": {"body":"<path fill=\"currentColor\" d=\"M10 21h4c0 1.1-.9 2-2 2s-2-.9-2-2m11-2v1H3v-1l2-2v-6c0-3.1 2-5.8 5-6.7V4c0-1.1.9-2 2-2s2 .9 2 2v.3c3 .9 5 3.6 5 6.7v6zm-4-8c0-2.8-2.2-5-5-5s-5 2.2-5 5v7h10z\"/>","width":24,"height":24},
"mdi:book-plus": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 1.1.3 2.12.81 3H6c-1.11 0-2-.89-2-2V4a2 2 0 0 1 2-2h1v7l2.5-1.5L12 9V2h6a2 2 0 0 1 2 2v9.09c-.33-.05-.66-.09-1-.09c-3.31 0-6 2.69-6 6m7-1v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24}, "mdi:book-plus": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 1.1.3 2.12.81 3H6c-1.11 0-2-.89-2-2V4a2 2 0 0 1 2-2h1v7l2.5-1.5L12 9V2h6a2 2 0 0 1 2 2v9.09c-.33-.05-.66-.09-1-.09c-3.31 0-6 2.69-6 6m7-1v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24},
"mdi:car": {"body":"<path fill=\"currentColor\" d=\"m5 11l1.5-4.5h11L19 11m-1.5 5a1.5 1.5 0 0 1-1.5-1.5a1.5 1.5 0 0 1 1.5-1.5a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-11 0A1.5 1.5 0 0 1 5 14.5A1.5 1.5 0 0 1 6.5 13A1.5 1.5 0 0 1 8 14.5A1.5 1.5 0 0 1 6.5 16M18.92 6c-.2-.58-.76-1-1.42-1h-11c-.66 0-1.22.42-1.42 1L3 12v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1h12v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-8z\"/>","width":24,"height":24}, "mdi:car": {"body":"<path fill=\"currentColor\" d=\"m5 11l1.5-4.5h11L19 11m-1.5 5a1.5 1.5 0 0 1-1.5-1.5a1.5 1.5 0 0 1 1.5-1.5a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-11 0A1.5 1.5 0 0 1 5 14.5A1.5 1.5 0 0 1 6.5 13A1.5 1.5 0 0 1 8 14.5A1.5 1.5 0 0 1 6.5 16M18.92 6c-.2-.58-.76-1-1.42-1h-11c-.66 0-1.22.42-1.42 1L3 12v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1h12v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-8z\"/>","width":24,"height":24},
"mdi:check": {"body":"<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>","width":24,"height":24}, "mdi:check": {"body":"<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>","width":24,"height":24},

@ -110,7 +110,7 @@
v-if="userStore.authenticated && !isRedirect" v-if="userStore.authenticated && !isRedirect"
flat flat
dense dense
:icon="pageStore.isWatching ? `mdi:bell` : `mdi:bell-outline`" :icon="pageStore.isWatching ? `mdi:bell` : `la:bell`"
:color="pageStore.isWatching ? `deep-orange-9` : `grey`" :color="pageStore.isWatching ? `deep-orange-9` : `grey`"
:aria-label="pageStore.isWatching ? t(`common.page.unwatch`) : t(`common.page.watch`)" :aria-label="pageStore.isWatching ? t(`common.page.unwatch`) : t(`common.page.watch`)"
:aria-pressed="pageStore.isWatching" :aria-pressed="pageStore.isWatching"

@ -321,7 +321,8 @@
dense dense
:type="inputTypeFor(cfg)" :type="inputTypeFor(cfg)"
:aria-label="cfg.title" :aria-label="cfg.title"
:disable="cfg.readOnly" /> :disable="cfg.readOnly"
@focus="(ev) => selectStoredSecret(ev, cfg)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
</template> </template>
@ -373,14 +374,6 @@
style="height: 100px; max-width: 300px" /> style="height: 100px; max-width: 300px" />
<div class="text-subtitle2 mt-2">{{ state.strategy.strategy.title }}</div> <div class="text-subtitle2 mt-2">{{ state.strategy.strategy.title }}</div>
<div class="text-caption mt-2">{{ state.strategy.strategy.description }}</div> <div class="text-caption mt-2">{{ state.strategy.strategy.description }}</div>
<div class="text-caption mt-2">
<strong>{{ state.strategy.strategy.vendor }}</strong>
</div>
<div class="text-caption">
<a :href="state.strategy.strategy.website" target="_blank" rel="noreferrer">{{
state.strategy.strategy.website
}}</a>
</div>
</w-card-section> </w-card-section>
</w-card> </w-card>
<div class="flex mt-4"> <div class="flex mt-4">
@ -516,6 +509,14 @@ function buildConfigEditor(props, values) {
config[key] = { config[key] = {
...prop, ...prop,
value: values?.[key] ?? prop.default, value: values?.[key] ?? prop.default,
/*
What the server sent, kept only for a sensitive prop.
The server never sends a stored secret back the field arrives holding a mask instead so
this is how the form tells a value that is still the server's from one somebody has typed.
See `selectStoredSecret`.
*/
...(prop.sensitive && { stored: values?.[key] ?? prop.default }),
...(prop.enum && { ...(prop.enum && {
enum: prop.enum.map((entry) => { enum: prop.enum.map((entry) => {
const [value, label] = entry.split('|') const [value, label] = entry.split('|')
@ -527,6 +528,24 @@ function buildConfigEditor(props, values) {
return config return config
} }
/**
* Select the whole of a masked secret as soon as its field is focused.
*
* A sensitive prop reads as a row of dots rather than as what is stored, in a field that is
* otherwise an ordinary text box: clicking into it and pasting a new client secret would append it
* to the mask and save the pair of them. Selecting the mask makes the first thing typed replace it.
*
* Only while the field still holds what the server sent, so that a value being edited is not
* repeatedly selected out from under whoever is editing it. Selecting changes nothing on its own
* focusing the field and leaving sends the mask back, which the server reads as "unchanged" and
* emptying the field still means the secret is to be removed.
*/
function selectStoredSecret(ev, cfg) {
if (cfg.sensitive && cfg.value === cfg.stored) {
ev.target.select()
}
}
function inputTypeFor(cfg) { function inputTypeFor(cfg) {
if (cfg.multiline) { if (cfg.multiline) {
return 'textarea' return 'textarea'

@ -206,7 +206,8 @@
dense dense
:type="inputTypeFor(cfg)" :type="inputTypeFor(cfg)"
:aria-label="cfg.title" :aria-label="cfg.title"
:disable="cfg.readOnly" /> :disable="cfg.readOnly"
@focus="(ev) => selectStoredSecret(ev, cfg)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
</template> </template>
@ -913,6 +914,14 @@ function buildConfigEditor(props, values) {
config[key] = { config[key] = {
...prop, ...prop,
value: values?.[key] ?? prop.default, value: values?.[key] ?? prop.default,
/*
What the server sent, kept only for a sensitive prop.
The server never sends a stored secret back the field arrives holding a mask instead so
this is how the form tells a value that is still the server's from one somebody has typed.
See `selectStoredSecret`.
*/
...(prop.sensitive && { stored: values?.[key] ?? prop.default }),
...(prop.enum && { ...(prop.enum && {
enum: prop.enum.map((entry) => { enum: prop.enum.map((entry) => {
const [value, label] = entry.split('|') const [value, label] = entry.split('|')
@ -939,6 +948,24 @@ function savedSnapshot(tgt) {
} }
} }
/**
* Select the whole of a masked secret as soon as its field is focused.
*
* A sensitive prop reads as a row of dots rather than as what is stored, in a field that is
* otherwise an ordinary text box: clicking into it and pasting a new key would append the key to the
* mask and save the pair of them. Selecting the mask makes the first thing typed replace it.
*
* Only while the field still holds what the server sent, so that a value being edited is not
* repeatedly selected out from under whoever is editing it. Selecting changes nothing on its own
* focusing the field and leaving sends the mask back, which the server reads as "unchanged" and
* emptying the field still means the secret is to be removed.
*/
function selectStoredSecret(ev, cfg) {
if (cfg.sensitive && cfg.value === cfg.stored) {
ev.target.select()
}
}
function inputTypeFor(cfg) { function inputTypeFor(cfg) {
if (cfg.multiline) { if (cfg.multiline) {
return 'textarea' return 'textarea'

Loading…
Cancel
Save