diff --git a/CLAUDE.md b/CLAUDE.md index 4f5337ee8..07dbe4ccd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -370,6 +370,17 @@ Consequences worth knowing: failures into `{ ok, error, statusCode, message }` JSON. - **Schema changes**: edit `db/schema.ts`, then `npm run db-generate` and commit the generated 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 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 @@ -431,8 +442,15 @@ import a tree that was put there from outside. `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 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 -deletes on a copy that failed. Credentials are optional on all three: left empty, each SDK falls back +Each of the three also takes a **`pathPrefix`**, which is the segments that key starts with — empty by +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 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 diff --git a/backend/api/authentication.ts b/backend/api/authentication.ts index a862b7e07..b0935cba1 100644 --- a/backend/api/authentication.ts +++ b/backend/api/authentication.ts @@ -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) } ) diff --git a/backend/api/schemas/authentication.ts b/backend/api/schemas/authentication.ts index 77bf0007f..6478f627f 100644 --- a/backend/api/schemas/authentication.ts +++ b/backend/api/schemas/authentication.ts @@ -63,12 +63,6 @@ export async function registerSchemas(app: FastifyInstance): Promise { color: { type: 'string' }, - vendor: { - type: 'string' - }, - website: { - type: 'string' - }, isAvailable: { type: 'boolean' }, @@ -132,7 +126,7 @@ export async function registerSchemas(app: FastifyInstance): Promise { 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 { 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.' } } }) diff --git a/backend/api/schemas/storage.ts b/backend/api/schemas/storage.ts index e7c6aee78..428a13a02 100644 --- a/backend/api/schemas/storage.ts +++ b/backend/api/schemas/storage.ts @@ -36,12 +36,6 @@ export async function registerSchemas(app: FastifyInstance): Promise { banner: { type: 'string' }, - vendor: { - type: 'string' - }, - website: { - type: 'string' - }, contentTypes: { type: 'object', description: @@ -108,7 +102,7 @@ export async function registerSchemas(app: FastifyInstance): Promise { 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 { 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.' } } }) diff --git a/backend/api/storage.ts b/backend/api/storage.ts index e01f4dec8..6e909ba95 100644 --- a/backend/api/storage.ts +++ b/backend/api/storage.ts @@ -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) + })) } } ) diff --git a/backend/helpers/common.ts b/backend/helpers/common.ts index 46068c1c4..183883201 100644 --- a/backend/helpers/common.ts +++ b/backend/helpers/common.ts @@ -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, + config: Record +): Record { + const masked: Record = { ...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 ): Record { diff --git a/backend/helpers/storageObjects.ts b/backend/helpers/storageObjects.ts index c6616ccbc..55eedb742 100644 --- a/backend/helpers/storageObjects.ts +++ b/backend/helpers/storageObjects.ts @@ -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 } diff --git a/backend/models/authentication.ts b/backend/models/authentication.ts index 58e78fed4..6ff1637ec 100644 --- a/backend/models/authentication.ts +++ b/backend/models/authentication.ts @@ -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 { 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 = {} 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) { diff --git a/backend/models/storage.ts b/backend/models/storage.ts index 3193da610..2b21bcee1 100644 --- a/backend/models/storage.ts +++ b/backend/models/storage.ts @@ -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 { 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 = {} 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) { diff --git a/backend/modules/authentication/github/definition.yml b/backend/modules/authentication/github/definition.yml index 653db2c57..3dcd968b3 100644 --- a/backend/modules/authentication/github/definition.yml +++ b/backend/modules/authentication/github/definition.yml @@ -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 diff --git a/backend/modules/authentication/google/definition.yml b/backend/modules/authentication/google/definition.yml index 94acb6582..aa1bd9dc9 100644 --- a/backend/modules/authentication/google/definition.yml +++ b/backend/modules/authentication/google/definition.yml @@ -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 diff --git a/backend/modules/authentication/local/definition.yml b/backend/modules/authentication/local/definition.yml index bc92a2245..b51af3e88 100644 --- a/backend/modules/authentication/local/definition.yml +++ b/backend/modules/authentication/local/definition.yml @@ -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 diff --git a/backend/modules/authentication/oidc/definition.yml b/backend/modules/authentication/oidc/definition.yml index 5b3c7ee13..ad7fd0ff5 100644 --- a/backend/modules/authentication/oidc/definition.yml +++ b/backend/modules/authentication/oidc/definition.yml @@ -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 diff --git a/backend/modules/storage/azure/definition.yml b/backend/modules/storage/azure/definition.yml index 6d639a7a9..7fb1d3617 100644 --- a/backend/modules/storage/azure/definition.yml +++ b/backend/modules/storage/azure/definition.yml @@ -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 diff --git a/backend/modules/storage/azure/storage.ts b/backend/modules/storage/azure/storage.ts index efc55b279..f81f17686 100644 --- a/backend/modules/storage/azure/storage.ts +++ b/backend/modules/storage/azure/storage.ts @@ -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) diff --git a/backend/modules/storage/gcs/definition.yml b/backend/modules/storage/gcs/definition.yml index 7a7c6ef92..c042b0d9a 100644 --- a/backend/modules/storage/gcs/definition.yml +++ b/backend/modules/storage/gcs/definition.yml @@ -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 diff --git a/backend/modules/storage/gcs/storage.ts b/backend/modules/storage/gcs/storage.ts index d7192603d..77c2b093c 100644 --- a/backend/modules/storage/gcs/storage.ts +++ b/backend/modules/storage/gcs/storage.ts @@ -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) diff --git a/backend/modules/storage/s3/definition.yml b/backend/modules/storage/s3/definition.yml index 6167e9a0d..54d8c2a76 100644 --- a/backend/modules/storage/s3/definition.yml +++ b/backend/modules/storage/s3/definition.yml @@ -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 diff --git a/backend/modules/storage/s3/storage.ts b/backend/modules/storage/s3/storage.ts index d7b432caa..e4bb7ae93 100644 --- a/backend/modules/storage/s3/storage.ts +++ b/backend/modules/storage/s3/storage.ts @@ -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) diff --git a/backend/modules/storage/sftp/definition.yml b/backend/modules/storage/sftp/definition.yml index bfdb33b9b..4fd297db6 100644 --- a/backend/modules/storage/sftp/definition.yml +++ b/backend/modules/storage/sftp/definition.yml @@ -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 diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 5f62a931c..27e701b7e 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ 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. - 267 icons. + 266 icons. */ export const BUNDLED_ICONS = { "la:angle-right": {"body":"","width":32,"height":32}, @@ -157,7 +157,6 @@ export const BUNDLED_ICONS = { "mdi:basketball": {"body":"","width":24,"height":24}, "mdi:bell": {"body":"","width":24,"height":24}, "mdi:bell-off-outline": {"body":"","width":24,"height":24}, - "mdi:bell-outline": {"body":"","width":24,"height":24}, "mdi:book-plus": {"body":"","width":24,"height":24}, "mdi:car": {"body":"","width":24,"height":24}, "mdi:check": {"body":"","width":24,"height":24}, diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue index e9da92422..7cbfad58e 100644 --- a/frontend/src/components/PageHeader.vue +++ b/frontend/src/components/PageHeader.vue @@ -110,7 +110,7 @@ v-if="userStore.authenticated && !isRedirect" flat dense - :icon="pageStore.isWatching ? `mdi:bell` : `mdi:bell-outline`" + :icon="pageStore.isWatching ? `mdi:bell` : `la:bell`" :color="pageStore.isWatching ? `deep-orange-9` : `grey`" :aria-label="pageStore.isWatching ? t(`common.page.unwatch`) : t(`common.page.watch`)" :aria-pressed="pageStore.isWatching" diff --git a/frontend/src/pages/AdminAuth.vue b/frontend/src/pages/AdminAuth.vue index 2e9588e3b..4d74d97f6 100644 --- a/frontend/src/pages/AdminAuth.vue +++ b/frontend/src/pages/AdminAuth.vue @@ -321,7 +321,8 @@ dense :type="inputTypeFor(cfg)" :aria-label="cfg.title" - :disable="cfg.readOnly" /> + :disable="cfg.readOnly" + @focus="(ev) => selectStoredSecret(ev, cfg)" /> @@ -373,14 +374,6 @@ style="height: 100px; max-width: 300px" />
{{ state.strategy.strategy.title }}
{{ state.strategy.strategy.description }}
-
- {{ state.strategy.strategy.vendor }} -
-
@@ -516,6 +509,14 @@ function buildConfigEditor(props, values) { config[key] = { ...prop, 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 && { enum: prop.enum.map((entry) => { const [value, label] = entry.split('|') @@ -527,6 +528,24 @@ function buildConfigEditor(props, values) { 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) { if (cfg.multiline) { return 'textarea' diff --git a/frontend/src/pages/AdminStorage.vue b/frontend/src/pages/AdminStorage.vue index dbae6d3b0..6eff54182 100644 --- a/frontend/src/pages/AdminStorage.vue +++ b/frontend/src/pages/AdminStorage.vue @@ -206,7 +206,8 @@ dense :type="inputTypeFor(cfg)" :aria-label="cfg.title" - :disable="cfg.readOnly" /> + :disable="cfg.readOnly" + @focus="(ev) => selectStoredSecret(ev, cfg)" /> @@ -913,6 +914,14 @@ function buildConfigEditor(props, values) { config[key] = { ...prop, 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 && { enum: prop.enum.map((entry) => { 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) { if (cfg.multiline) { return 'textarea'