fix(custom-widgets): harden v2 authoring
This commit is contained in:
171 files changed
+20311
-50527
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
|
||||
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39
|
||||
|
||||
FROM node:24.18.0-alpine AS dependencies
|
||||
WORKDIR /app
|
||||
ENV COREPACK_HOME=/corepack
|
||||
@@ -16,29 +18,17 @@ RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set
|
||||
FROM dependencies AS website
|
||||
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
|
||||
|
||||
FROM alpine:3.22 AS pocketbase
|
||||
ARG TARGETARCH
|
||||
ARG POCKETBASE_VERSION=0.39.6
|
||||
RUN apk add --no-cache ca-certificates curl unzip
|
||||
RUN case "${TARGETARCH}" in amd64) PB_ARCH=amd64 ;; arm64) PB_ARCH=arm64 ;; arm) PB_ARCH=armv7 ;; *) exit 1 ;; esac && \
|
||||
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
|
||||
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" -o "/tmp/${ARCHIVE}" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" -o /tmp/checksums.txt && \
|
||||
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && unzip "/tmp/${ARCHIVE}" pocketbase -d /out
|
||||
|
||||
FROM alpine:3.22 AS development
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
FROM ${POCKETBASE_IMAGE} AS development
|
||||
ENV PORT=8090
|
||||
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public
|
||||
EXPOSE 8090
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
|
||||
ENTRYPOINT ["workshop-entrypoint"]
|
||||
|
||||
FROM alpine:3.22 AS production
|
||||
RUN apk add --no-cache ca-certificates tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
FROM ${POCKETBASE_IMAGE} AS production
|
||||
ENV PORT=8090
|
||||
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY --from=website /app/apps/docs/build /pb_public
|
||||
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
COPY apps/workshop/pb_hooks /pb_hooks
|
||||
@@ -46,5 +36,5 @@ COPY apps/workshop/pb_migrations /pb_migrations
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations /pb_public
|
||||
USER pocketbase
|
||||
EXPOSE 8090
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
|
||||
ENTRYPOINT ["workshop-entrypoint"]
|
||||
+34
-3
@@ -2,7 +2,12 @@
|
||||
|
||||
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per submission.
|
||||
|
||||
It intentionally has no approval queue, user bans, reusable widget connections, or remote control over installed widgets. Submissions publish immediately. Authenticated users can vote and report; records in `workshop_admins` can inspect reports, dismiss them, and delete any submission.
|
||||
The runtime image extends `ghcr.io/muchobien/pocketbase:0.39`; Homarr adds only the built documentation, PocketBase
|
||||
collections, one small user-role guard, and its explicit serve arguments.
|
||||
|
||||
It intentionally has no approval queue, user bans, comments, email notifications, audit log, reusable widget connections,
|
||||
or remote control over installed widgets. Submissions publish immediately. Authenticated users can vote and report;
|
||||
users with `isAdmin` enabled can inspect and dismiss reports or delete any submission.
|
||||
|
||||
## Local development
|
||||
|
||||
@@ -10,7 +15,15 @@ It intentionally has no approval queue, user bans, reusable widget connections,
|
||||
PB_EXPOSE_PORT=18090 docker compose -f apps/workshop/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, configure GitHub OAuth on the `users` collection, and add Workshop administrators through the `workshop_admins` collection.
|
||||
Compose passes the selected port to PocketBase as `PORT`, so it listens on the same port inside and outside the
|
||||
container. With the image directly, set both values: `docker run -e PORT=3003 -p 3003:3003 <workshop-image>`.
|
||||
|
||||
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, and configure GitHub OAuth on the `users` collection.
|
||||
|
||||
The `users.isAdmin` field defaults to `false` and cannot be changed by regular users. Appoint administrators from the
|
||||
PocketBase dashboard with a superuser account.
|
||||
|
||||
Users must sign in again after the role changes. Setting the value back to `false` removes Workshop administrator access.
|
||||
|
||||
The GitHub OAuth application needs one callback for the Workshop host:
|
||||
|
||||
@@ -26,6 +39,12 @@ Run the disposable service integration test from the repository root:
|
||||
pnpm test:workshop
|
||||
```
|
||||
|
||||
The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
|
||||
`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
|
||||
PocketBase API rules enforce submission ownership, one vote/report per user and submission, and administrator-only
|
||||
moderation. Reports are dismissed by deleting them; PocketBase cascade deletion removes related votes and reports when a
|
||||
submission is deleted.
|
||||
|
||||
## Production docs preview
|
||||
|
||||
Build the documentation and serve it from PocketBase using the same production image layout as the hosted Workshop:
|
||||
@@ -36,10 +55,22 @@ pnpm docker:docs
|
||||
|
||||
Open `http://127.0.0.1:3003`. Set `DOCS_EXPOSE_PORT` to use another host port. The preview does not mount or persist PocketBase data.
|
||||
|
||||
Point a local Homarr checkout at it with:
|
||||
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:3003 pnpm dev
|
||||
```
|
||||
|
||||
The production Homarr image reads the same variable when the container starts:
|
||||
|
||||
```sh
|
||||
docker run -p 7575:7575 -e WORKSHOP_API_URL=http://127.0.0.1:3003 <homarr-image>
|
||||
```
|
||||
|
||||
This command starts the profiled `docs` service from `apps/workshop/docker-compose.yml`. It does not start the development `workshop` service. Stop and remove the preview with:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml --profile docs rm --stop --force docs
|
||||
```
|
||||
|
||||
Production data lives in `/pb_data`. The image serves the built Homarr documentation and Workshop UI from `/pb_public`.
|
||||
PocketBase data lives in `/pb_data`. Docker Compose persists it in the `pb_data` named volume for both the development Workshop service and the production documentation/Workshop service. The image serves the built Homarr documentation and Workshop UI from `/pb_public`.
|
||||
@@ -6,15 +6,14 @@ services:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: development
|
||||
args:
|
||||
POCKETBASE_VERSION: 0.39.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
PORT: ${PB_EXPOSE_PORT:-8090}
|
||||
ports:
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
|
||||
volumes:
|
||||
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
|
||||
- pb_data:/pb_data
|
||||
- ./pb_hooks:/pb_hooks:ro
|
||||
- ./pb_migrations:/pb_migrations:ro
|
||||
security_opt:
|
||||
@@ -27,11 +26,15 @@ services:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: production
|
||||
args:
|
||||
POCKETBASE_VERSION: 0.39.6
|
||||
environment:
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
PORT: ${DOCS_EXPOSE_PORT:-3003}
|
||||
ports:
|
||||
- "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:8090"
|
||||
- "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:${DOCS_EXPOSE_PORT:-3003}"
|
||||
volumes:
|
||||
- pb_data:/pb_data
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
@@ -2,7 +2,7 @@
|
||||
set -eu
|
||||
|
||||
exec pocketbase serve \
|
||||
--http=0.0.0.0:8090 \
|
||||
--http="0.0.0.0:${PORT:-8090}" \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-*}" \
|
||||
--dir=/pb_data \
|
||||
--hooksDir=/pb_hooks \
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordCreate((e) => {
|
||||
e.record.set("isAdmin", false);
|
||||
e.next();
|
||||
}, "users");
|
||||
@@ -1,633 +0,0 @@
|
||||
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
const AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
|
||||
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
const NETWORK_SCOPES = ["public", "private", "loopback"];
|
||||
const PERMISSIONS = ["view", "modify", "full"];
|
||||
const PARAMETER_TYPES = ["string", "number", "boolean"];
|
||||
const TOP_LEVEL_FIELDS = [
|
||||
"$schema",
|
||||
"name",
|
||||
"description",
|
||||
"iconUrl",
|
||||
"sources",
|
||||
"requests",
|
||||
"optionsSchema",
|
||||
"defaultOptions",
|
||||
"template",
|
||||
];
|
||||
const SOURCE_FIELDS = ["id", "name", "baseUrl", "networkScope", "auth"];
|
||||
const REQUEST_FIELDS = [
|
||||
"id",
|
||||
"sourceId",
|
||||
"kind",
|
||||
"method",
|
||||
"pathTemplate",
|
||||
"parameters",
|
||||
"queryTemplate",
|
||||
"bodyTemplate",
|
||||
"staticHeaders",
|
||||
"auth",
|
||||
"minimumBoardPermission",
|
||||
"trigger",
|
||||
"cacheTtlSeconds",
|
||||
"confirmation",
|
||||
"invalidates",
|
||||
];
|
||||
const OPTION_FIELDS = [
|
||||
"type",
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"enum",
|
||||
"const",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"multipleOf",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"format",
|
||||
"items",
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"x-homarr",
|
||||
];
|
||||
const OPTION_TYPES = ["object", "array", "string", "number", "integer", "boolean", "null"];
|
||||
const OPTION_CONTROLS = [
|
||||
"text",
|
||||
"textarea",
|
||||
"number",
|
||||
"switch",
|
||||
"select",
|
||||
"multi-select",
|
||||
"slider",
|
||||
"date",
|
||||
"time",
|
||||
"color",
|
||||
"icon",
|
||||
"url",
|
||||
"duration",
|
||||
"timeZone",
|
||||
"json",
|
||||
];
|
||||
const OPTION_FORMATS = ["date", "date-time", "time", "color", "uri", "icon", "duration", "time-zone"];
|
||||
const RESERVED_HEADERS = [
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
];
|
||||
const SENSITIVE_KEY =
|
||||
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/i;
|
||||
const CREDENTIAL_TEXT =
|
||||
/(?:\bauthorization\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._~+/%-]{8,}|\b(?:api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)\s*[:=]\s*["'][^"']{4,}["'])/i;
|
||||
|
||||
function bad(message) {
|
||||
throw new BadRequestError(message);
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validId(value) {
|
||||
return typeof value === "string" && /^[a-z][a-z0-9_-]{0,63}$/.test(value);
|
||||
}
|
||||
|
||||
function validBindingId(value) {
|
||||
return typeof value === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
|
||||
}
|
||||
|
||||
function validHttpUrl(value) {
|
||||
return typeof value === "string" && /^https?:\/\/[^\s]+$/i.test(value);
|
||||
}
|
||||
|
||||
function protectedHeader(name) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return (
|
||||
RESERVED_HEADERS.includes(normalized) ||
|
||||
normalized.startsWith("proxy-") ||
|
||||
normalized.startsWith("sec-") ||
|
||||
normalized.startsWith("x-forwarded-")
|
||||
);
|
||||
}
|
||||
|
||||
function validHeaderName(name) {
|
||||
return (
|
||||
typeof name === "string" &&
|
||||
name.length <= 256 &&
|
||||
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) &&
|
||||
!protectedHeader(name)
|
||||
);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 31 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateSource(source) {
|
||||
if (
|
||||
!isObject(source) ||
|
||||
Object.keys(source).some((key) => !SOURCE_FIELDS.includes(key)) ||
|
||||
!validId(source.id) ||
|
||||
typeof source.name !== "string" ||
|
||||
!source.name.trim() ||
|
||||
source.name.length > 128 ||
|
||||
typeof source.baseUrl !== "string" ||
|
||||
!NETWORK_SCOPES.includes(source.networkScope) ||
|
||||
!isObject(source.auth) ||
|
||||
!AUTH_TYPES.includes(source.auth.type)
|
||||
)
|
||||
bad("Widget contains an invalid source");
|
||||
if (!/^https?:\/\/[^/?#\s]+(?:\/[^?#\s]*)?$/i.test(source.baseUrl)) bad("Widget source URL is invalid");
|
||||
const authority = source.baseUrl.replace(/^https?:\/\//i, "").split("/")[0];
|
||||
if (authority.includes("@")) bad("Widget source URLs must not contain credentials or query values");
|
||||
const authFields =
|
||||
source.auth.type === "apiKeyHeader"
|
||||
? ["type", "headerName"]
|
||||
: source.auth.type === "apiKeyQuery"
|
||||
? ["type", "parameterName"]
|
||||
: ["type"];
|
||||
if (Object.keys(source.auth).some((key) => !authFields.includes(key))) bad("Widget source authentication is invalid");
|
||||
if (source.auth.type === "apiKeyHeader" && !validHeaderName(source.auth.headerName))
|
||||
bad("API key authentication requires a safe HTTP header name");
|
||||
if (
|
||||
source.auth.type === "apiKeyQuery" &&
|
||||
(typeof source.auth.parameterName !== "string" ||
|
||||
!source.auth.parameterName ||
|
||||
source.auth.parameterName.length > 256 ||
|
||||
hasControlCharacter(source.auth.parameterName))
|
||||
)
|
||||
bad("API key query authentication requires a safe parameterName");
|
||||
}
|
||||
|
||||
function validateRequest(request, sourceIds) {
|
||||
if (
|
||||
!isObject(request) ||
|
||||
Object.keys(request).some((key) => !REQUEST_FIELDS.includes(key)) ||
|
||||
!validId(request.id) ||
|
||||
!sourceIds.has(request.sourceId) ||
|
||||
!["query", "action"].includes(request.kind) ||
|
||||
!METHODS.includes(request.method) ||
|
||||
typeof request.pathTemplate !== "string" ||
|
||||
!request.pathTemplate.startsWith("/") ||
|
||||
request.pathTemplate.startsWith("//") ||
|
||||
request.pathTemplate.includes("\\") ||
|
||||
request.pathTemplate.includes("#") ||
|
||||
!isObject(request.parameters) ||
|
||||
!["inherit", "none"].includes(request.auth) ||
|
||||
!PERMISSIONS.includes(request.minimumBoardPermission)
|
||||
)
|
||||
bad("Widget contains an invalid request");
|
||||
if (
|
||||
Object.keys(request.parameters).length > 32 ||
|
||||
Object.entries(request.parameters).some(([name, type]) => !validBindingId(name) || !PARAMETER_TYPES.includes(type))
|
||||
)
|
||||
bad("Widget request parameters are invalid");
|
||||
const trigger = request.trigger || "manual";
|
||||
if (!["load", "manual"].includes(trigger) || (request.kind === "action" && trigger === "load"))
|
||||
bad("Actions cannot run automatically");
|
||||
if (request.kind === "query" && request.confirmation !== undefined) bad("Only actions can require confirmation");
|
||||
if (request.kind === "query" && request.invalidates !== undefined) bad("Only actions can invalidate queries");
|
||||
if (request.method === "DELETE" && request.minimumBoardPermission !== "full")
|
||||
bad("DELETE requests require full permission");
|
||||
if (
|
||||
request.queryTemplate !== undefined &&
|
||||
(!isObject(request.queryTemplate) || Object.keys(request.queryTemplate).some((key) => !key || key.length > 256))
|
||||
)
|
||||
bad("Widget query template is invalid");
|
||||
if (
|
||||
request.confirmation !== undefined &&
|
||||
(!isObject(request.confirmation) ||
|
||||
Object.keys(request.confirmation).some(
|
||||
(key) => !["title", "message", "confirmLabel", "destructive"].includes(key),
|
||||
) ||
|
||||
typeof request.confirmation.title !== "string" ||
|
||||
!request.confirmation.title ||
|
||||
request.confirmation.title.length > 128 ||
|
||||
typeof request.confirmation.message !== "string" ||
|
||||
!request.confirmation.message ||
|
||||
request.confirmation.message.length > 512 ||
|
||||
(request.confirmation.confirmLabel !== undefined &&
|
||||
(typeof request.confirmation.confirmLabel !== "string" ||
|
||||
!request.confirmation.confirmLabel ||
|
||||
request.confirmation.confirmLabel.length > 64)) ||
|
||||
(request.confirmation.destructive !== undefined && typeof request.confirmation.destructive !== "boolean"))
|
||||
)
|
||||
bad("Widget action confirmation is invalid");
|
||||
if (
|
||||
request.invalidates !== undefined &&
|
||||
(!Array.isArray(request.invalidates) ||
|
||||
request.invalidates.length > 32 ||
|
||||
request.invalidates.some((id) => !validId(id)))
|
||||
)
|
||||
bad("Widget invalidation targets are invalid");
|
||||
if (
|
||||
request.cacheTtlSeconds !== undefined &&
|
||||
(!Number.isInteger(request.cacheTtlSeconds) ||
|
||||
request.cacheTtlSeconds < 0 ||
|
||||
request.cacheTtlSeconds > 3600 ||
|
||||
(request.kind === "action" && request.cacheTtlSeconds !== 0))
|
||||
)
|
||||
bad("Widget request cache duration is invalid");
|
||||
if (request.staticHeaders !== undefined) {
|
||||
if (!isObject(request.staticHeaders) || Object.keys(request.staticHeaders).length > 32)
|
||||
bad("Widget static headers are invalid");
|
||||
for (const [name, value] of Object.entries(request.staticHeaders)) {
|
||||
if (!validHeaderName(name) || typeof value !== "string" || value.length > 8192)
|
||||
bad("Widget static headers are invalid");
|
||||
if ((SENSITIVE_KEY.test(name) && value.trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(value))
|
||||
bad("Widget static headers must not contain credentials");
|
||||
}
|
||||
}
|
||||
const used = new Set();
|
||||
const placeholderPattern = /\{([^{}]+)\}/g;
|
||||
let placeholder;
|
||||
while ((placeholder = placeholderPattern.exec(request.pathTemplate)) !== null) used.add(placeholder[1]);
|
||||
collectParams(request.queryTemplate, used);
|
||||
collectParams(request.bodyTemplate, used);
|
||||
for (const name of used)
|
||||
if (!Object.prototype.hasOwnProperty.call(request.parameters, name))
|
||||
bad("Widget request uses an undeclared parameter");
|
||||
}
|
||||
|
||||
function collectParams(value, result) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectParams(item, result);
|
||||
return;
|
||||
}
|
||||
if (!isObject(value)) return;
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0][0] === "$param" && typeof entries[0][1] === "string") {
|
||||
result.add(entries[0][1]);
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) collectParams(entry[1], result);
|
||||
}
|
||||
|
||||
function validateOptionsMetadata(metadata, schema) {
|
||||
if (
|
||||
!isObject(metadata) ||
|
||||
Object.keys(metadata).some((key) => !["control", "placeholder", "advanced", "order", "optionsSource"].includes(key))
|
||||
)
|
||||
bad("Widget option presentation metadata is invalid");
|
||||
if (metadata.control !== undefined && !OPTION_CONTROLS.includes(metadata.control))
|
||||
bad("Widget option control is invalid");
|
||||
const controlsByType = {
|
||||
string: ["text", "textarea", "select", "date", "time", "color", "icon", "url", "timeZone", "json"],
|
||||
number: ["number", "select", "slider", "duration", "json"],
|
||||
integer: ["number", "select", "slider", "duration", "json"],
|
||||
boolean: ["switch", "select", "json"],
|
||||
array: ["multi-select", "json"],
|
||||
object: ["json"],
|
||||
null: ["json"],
|
||||
};
|
||||
if (
|
||||
metadata.control !== undefined &&
|
||||
schema.type !== undefined &&
|
||||
!controlsByType[schema.type].includes(metadata.control)
|
||||
)
|
||||
bad("Widget option control does not match its type");
|
||||
if (metadata.placeholder !== undefined && typeof metadata.placeholder !== "string")
|
||||
bad("Widget option placeholder is invalid");
|
||||
if (metadata.advanced !== undefined && typeof metadata.advanced !== "boolean")
|
||||
bad("Widget advanced option metadata is invalid");
|
||||
if (metadata.order !== undefined && (typeof metadata.order !== "number" || !Number.isFinite(metadata.order)))
|
||||
bad("Widget option order is invalid");
|
||||
if (metadata.optionsSource !== undefined) {
|
||||
const source = metadata.optionsSource;
|
||||
if (
|
||||
!isObject(source) ||
|
||||
Object.keys(source).some((key) => !["requestId", "itemsPath", "valuePath", "labelPath"].includes(key)) ||
|
||||
!["requestId", "valuePath", "labelPath"].every((key) => typeof source[key] === "string" && source[key]) ||
|
||||
(source.itemsPath !== undefined && (typeof source.itemsPath !== "string" || !source.itemsPath))
|
||||
)
|
||||
bad("Widget dynamic option source is invalid");
|
||||
if (!["select", "multi-select"].includes(metadata.control)) bad("Widget dynamic options require a select control");
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionsSchema(schema, root, requireType) {
|
||||
if (!isObject(schema) || Object.keys(schema).some((key) => !OPTION_FIELDS.includes(key)))
|
||||
bad("Widget options schema is invalid");
|
||||
if (requireType !== false && !OPTION_TYPES.includes(schema.type)) bad("Every widget option needs one supported type");
|
||||
if (schema.type !== undefined && !OPTION_TYPES.includes(schema.type)) bad("Widget option type is invalid");
|
||||
if (root && schema.type !== "object") bad("Widget options schema root must be an object");
|
||||
if (schema.type === "object" && schema.additionalProperties !== false)
|
||||
bad("Widget object options must reject unknown fields");
|
||||
for (const key of ["title", "description"])
|
||||
if (schema[key] !== undefined && typeof schema[key] !== "string") bad("Widget option labels are invalid");
|
||||
for (const key of ["minimum", "maximum", "multipleOf"])
|
||||
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key])))
|
||||
bad("Widget numeric option constraints are invalid");
|
||||
if (schema.multipleOf !== undefined && schema.multipleOf <= 0) bad("Widget multipleOf must be positive");
|
||||
if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum)
|
||||
bad("Widget option bounds are invalid");
|
||||
for (const key of ["minLength", "maxLength", "minItems", "maxItems"])
|
||||
if (schema[key] !== undefined && (!Number.isInteger(schema[key]) || schema[key] < 0))
|
||||
bad("Widget option size constraints are invalid");
|
||||
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 100))
|
||||
bad("Widget option enum is invalid");
|
||||
if (schema.format !== undefined && (!OPTION_FORMATS.includes(schema.format) || schema.type !== "string"))
|
||||
bad("Widget option format is invalid");
|
||||
if (Array.isArray(schema.enum) && schema.enum.some((entry) => !optionMatches({ type: schema.type }, entry)))
|
||||
bad("Widget option enum values do not match their type");
|
||||
if (schema.const !== undefined && !optionMatches({ type: schema.type }, schema.const))
|
||||
bad("Widget option constant does not match its type");
|
||||
if (schema.properties !== undefined) {
|
||||
if (!isObject(schema.properties)) bad("Widget option properties are invalid");
|
||||
for (const [name, child] of Object.entries(schema.properties)) {
|
||||
if (!validBindingId(name))
|
||||
bad("Widget option names must start with a letter and contain letters, numbers, - or _");
|
||||
if (SENSITIVE_KEY.test(name)) bad("Credentials must use source authentication, not widget options");
|
||||
validateOptionsSchema(child, false, true);
|
||||
}
|
||||
if (schema.type !== undefined && schema.type !== "object") bad("Widget option properties require an object type");
|
||||
if (
|
||||
schema.required !== undefined &&
|
||||
(!Array.isArray(schema.required) ||
|
||||
schema.required.some(
|
||||
(key) => typeof key !== "string" || !Object.prototype.hasOwnProperty.call(schema.properties, key),
|
||||
))
|
||||
)
|
||||
bad("Widget required options are invalid");
|
||||
}
|
||||
if (schema.required !== undefined && !Array.isArray(schema.required)) bad("Widget required options are invalid");
|
||||
if (schema.type === "array" && schema.items === undefined) bad("Widget array options require items");
|
||||
if (schema.items !== undefined) {
|
||||
if (schema.type !== undefined && schema.type !== "array") bad("Widget option items require an array type");
|
||||
validateOptionsSchema(schema.items, false, true);
|
||||
}
|
||||
for (const branch of ["if", "then", "else"])
|
||||
if (schema[branch] !== undefined) validateOptionsSchema(schema[branch], false, branch !== "if");
|
||||
if (schema["x-homarr"] !== undefined) validateOptionsMetadata(schema["x-homarr"], schema);
|
||||
}
|
||||
|
||||
function optionMatches(schema, value) {
|
||||
if (schema.const !== undefined && value !== schema.const) return false;
|
||||
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false;
|
||||
if (schema.type === "object" && !isObject(value)) return false;
|
||||
if (schema.type === "array" && !Array.isArray(value)) return false;
|
||||
if (schema.type === "integer" && (typeof value !== "number" || !Number.isInteger(value))) return false;
|
||||
if (schema.type === "number" && typeof value !== "number") return false;
|
||||
if (schema.type === "string" && typeof value !== "string") return false;
|
||||
if (schema.type === "boolean" && typeof value !== "boolean") return false;
|
||||
if (schema.type === "null" && value !== null) return false;
|
||||
if (isObject(schema.properties) && isObject(value)) {
|
||||
for (const [key, child] of Object.entries(schema.properties)) {
|
||||
if (value[key] !== undefined && isObject(child) && !optionMatches(child, value[key])) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateOptionsValue(schema, value) {
|
||||
if (!optionMatches(schema, value)) bad("Widget default options do not match their schema");
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(value)) bad("Widget default options do not match their schema");
|
||||
const branch =
|
||||
isObject(schema.if) && optionMatches(schema.if, value)
|
||||
? schema.then
|
||||
: isObject(schema.if)
|
||||
? schema.else
|
||||
: undefined;
|
||||
const properties = Object.assign(
|
||||
{},
|
||||
isObject(schema.properties) ? schema.properties : {},
|
||||
isObject(branch) && isObject(branch.properties) ? branch.properties : {},
|
||||
);
|
||||
const required = (Array.isArray(schema.required) ? schema.required : []).concat(
|
||||
isObject(branch) && Array.isArray(branch.required) ? branch.required : [],
|
||||
);
|
||||
for (const key of required) if (value[key] === undefined) bad("Widget default options omit a required value");
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (!isObject(properties[key])) bad("Widget default options contain an unknown value");
|
||||
validateOptionsValue(properties[key], child);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(value)) bad("Widget default options do not match their schema");
|
||||
if (isObject(schema.items)) for (const item of value) validateOptionsValue(schema.items, item);
|
||||
return;
|
||||
}
|
||||
if (schema.type === "integer" && (typeof value !== "number" || !Number.isInteger(value)))
|
||||
bad("Widget default options do not match their schema");
|
||||
if (schema.type === "number" && typeof value !== "number") bad("Widget default options do not match their schema");
|
||||
if (schema.type === "string" && typeof value !== "string") bad("Widget default options do not match their schema");
|
||||
if (schema.type === "boolean" && typeof value !== "boolean") bad("Widget default options do not match their schema");
|
||||
if (schema.type === "null" && value !== null) bad("Widget default options do not match their schema");
|
||||
}
|
||||
|
||||
function collectDynamicOptionRequestIds(schema, result) {
|
||||
if (!isObject(schema)) return;
|
||||
const metadata = schema["x-homarr"];
|
||||
if (isObject(metadata) && isObject(metadata.optionsSource)) result.add(metadata.optionsSource.requestId);
|
||||
if (isObject(schema.properties))
|
||||
for (const child of Object.values(schema.properties)) collectDynamicOptionRequestIds(child, result);
|
||||
if (schema.items !== undefined) collectDynamicOptionRequestIds(schema.items, result);
|
||||
for (const branch of ["if", "then", "else"])
|
||||
if (schema[branch] !== undefined) collectDynamicOptionRequestIds(schema[branch], result);
|
||||
}
|
||||
|
||||
function containsCredential(value, key) {
|
||||
if (SENSITIVE_KEY.test(key || "") && typeof value === "string" && value.trim()) return true;
|
||||
if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, ""));
|
||||
if (!isObject(value)) return false;
|
||||
if (Object.keys(value).length === 1 && typeof value.$param === "string") return false;
|
||||
return Object.entries(value).some(([childKey, child]) => containsCredential(child, childKey));
|
||||
}
|
||||
|
||||
function containsPrototypeKey(value) {
|
||||
if (Array.isArray(value)) return value.some(containsPrototypeKey);
|
||||
if (!isObject(value)) return false;
|
||||
return Object.entries(value).some(
|
||||
([key, child]) =>
|
||||
["__proto__", "prototype", "constructor"].includes(key.toLowerCase()) || containsPrototypeKey(child),
|
||||
);
|
||||
}
|
||||
|
||||
function validateTemplate(template) {
|
||||
if (typeof template !== "string" || !template.trim() || template.length > 50000) bad("Widget template is invalid");
|
||||
if (
|
||||
/(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s|\{[^{}]*\bstate(?:\.|\[))/i.test(
|
||||
template,
|
||||
)
|
||||
)
|
||||
bad("Widget template requests a blocked capability");
|
||||
}
|
||||
|
||||
function validateTemplateRequests(template, requests) {
|
||||
const byId = new Map(requests.map((request) => [request.id, request]));
|
||||
const componentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
|
||||
let match;
|
||||
while ((match = componentPattern.exec(template)) !== null) {
|
||||
const idMatch = match[2].match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
|
||||
const requestId = idMatch && (idMatch[1] || idMatch[2]);
|
||||
if (!requestId) bad(`${match[1]} must use a literal requestId`);
|
||||
const request = byId.get(requestId);
|
||||
const expectedKind = match[1] === "SubFetch" ? "query" : "action";
|
||||
if (!request || request.kind !== expectedKind) bad(`${match[1]} references an invalid request`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemplateBindings(template) {
|
||||
const bindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/g;
|
||||
let match;
|
||||
while ((match = bindingPattern.exec(template)) !== null) {
|
||||
const inputName = match[1] || match[2];
|
||||
if (!inputName) bad("bind must use a literal input name");
|
||||
if (!validBindingId(inputName)) bad("bind must use a valid input name");
|
||||
}
|
||||
}
|
||||
|
||||
function validateSubmission(record) {
|
||||
const content = record.getString("content");
|
||||
let widget;
|
||||
try {
|
||||
widget = JSON.parse(content);
|
||||
} catch {
|
||||
bad("Widget content is not valid JSON");
|
||||
}
|
||||
if (
|
||||
!isObject(widget) ||
|
||||
widget.$schema !== WIDGET_SCHEMA ||
|
||||
typeof widget.name !== "string" ||
|
||||
!widget.name.trim() ||
|
||||
widget.name.length > 128 ||
|
||||
(widget.description !== undefined && (typeof widget.description !== "string" || widget.description.length > 512)) ||
|
||||
(widget.iconUrl !== undefined && (!validHttpUrl(widget.iconUrl) || /^https?:\/\/[^/]*@/i.test(widget.iconUrl))) ||
|
||||
Object.keys(widget).some((key) => !TOP_LEVEL_FIELDS.includes(key)) ||
|
||||
!Array.isArray(widget.sources) ||
|
||||
widget.sources.length < 1 ||
|
||||
widget.sources.length > 8 ||
|
||||
!Array.isArray(widget.requests) ||
|
||||
widget.requests.length > 64 ||
|
||||
!isObject(widget.defaultOptions)
|
||||
)
|
||||
bad("Widget does not match homarr-custom-widget-v2");
|
||||
validateOptionsSchema(widget.optionsSchema, true, true);
|
||||
validateOptionsValue(widget.optionsSchema, widget.defaultOptions);
|
||||
if (
|
||||
widget.iconUrl &&
|
||||
/[?&](?:authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)=/i.test(
|
||||
widget.iconUrl,
|
||||
)
|
||||
)
|
||||
bad("Widget icon URLs must not contain credentials");
|
||||
const sourceIds = new Set();
|
||||
for (const source of widget.sources) {
|
||||
validateSource(source);
|
||||
if (sourceIds.has(source.id)) bad("Widget source IDs must be unique");
|
||||
sourceIds.add(source.id);
|
||||
}
|
||||
const requestIds = new Set();
|
||||
for (const request of widget.requests) {
|
||||
validateRequest(request, sourceIds);
|
||||
if (requestIds.has(request.id)) bad("Widget request IDs must be unique");
|
||||
requestIds.add(request.id);
|
||||
}
|
||||
for (const request of widget.requests)
|
||||
for (const target of request.invalidates || []) {
|
||||
const targetRequest = widget.requests.find((candidate) => candidate.id === target);
|
||||
if (!targetRequest || targetRequest.kind !== "query") bad("Widget invalidates an unknown or non-query request");
|
||||
}
|
||||
for (const request of widget.requests) {
|
||||
if (request.auth !== "inherit") continue;
|
||||
const source = widget.sources.find((candidate) => candidate.id === request.sourceId);
|
||||
if (
|
||||
source.auth.type === "apiKeyHeader" &&
|
||||
Object.keys(request.staticHeaders || {}).some(
|
||||
(name) => name.toLowerCase() === source.auth.headerName.toLowerCase(),
|
||||
)
|
||||
)
|
||||
bad("Widget request overrides source authentication");
|
||||
if (
|
||||
source.auth.type === "apiKeyQuery" &&
|
||||
Object.keys(request.queryTemplate || {}).some(
|
||||
(name) => name.toLowerCase() === source.auth.parameterName.toLowerCase(),
|
||||
)
|
||||
)
|
||||
bad("Widget request overrides source authentication");
|
||||
}
|
||||
const dynamicOptionRequestIds = new Set();
|
||||
collectDynamicOptionRequestIds(widget.optionsSchema, dynamicOptionRequestIds);
|
||||
for (const requestId of dynamicOptionRequestIds) {
|
||||
const request = widget.requests.find((candidate) => candidate.id === requestId);
|
||||
if (!request || request.kind !== "query") bad("Widget dynamic options must reference a query request");
|
||||
}
|
||||
validateTemplate(widget.template);
|
||||
validateTemplateRequests(widget.template, widget.requests);
|
||||
validateTemplateBindings(widget.template);
|
||||
for (const request of widget.requests) {
|
||||
for (const [name, value] of Object.entries(request.staticHeaders || {})) {
|
||||
if ((SENSITIVE_KEY.test(name) && String(value).trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(String(value)))
|
||||
bad("Widget static headers must not contain credentials");
|
||||
}
|
||||
}
|
||||
if (
|
||||
CREDENTIAL_TEXT.test(widget.template) ||
|
||||
containsCredential(widget.defaultOptions, "") ||
|
||||
widget.requests.some(
|
||||
(request) => containsCredential(request.bodyTemplate, "") || containsCredential(request.queryTemplate, ""),
|
||||
)
|
||||
)
|
||||
bad("Widget exports must not contain credentials");
|
||||
if (
|
||||
containsPrototypeKey(widget.optionsSchema) ||
|
||||
containsPrototypeKey(widget.defaultOptions) ||
|
||||
widget.requests.some(
|
||||
(request) => containsPrototypeKey(request.queryTemplate) || containsPrototypeKey(request.bodyTemplate),
|
||||
)
|
||||
)
|
||||
bad("Widget contains a prototype-pollution key");
|
||||
record.set("contentHash", $security.sha256(content));
|
||||
}
|
||||
|
||||
function isAdmin(app, userId) {
|
||||
try {
|
||||
app.findFirstRecordByFilter("workshop_admins", "user = {:user}", { user: userId });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(record) {
|
||||
const data = record.publicExport();
|
||||
if (typeof data.content === "string") {
|
||||
data.contentHash = $security.sha256(data.content);
|
||||
delete data.content;
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
function audit(app, actor, action, values) {
|
||||
const record = new Record(app.findCollectionByNameOrId("workshop_admin_actions"));
|
||||
record.set("actor", actor.id);
|
||||
record.set("action", action);
|
||||
Object.entries(values).forEach(([key, value]) => record.set(key, value));
|
||||
app.save(record);
|
||||
}
|
||||
|
||||
module.exports = { audit, isAdmin, snapshot, validateSubmission };
|
||||
@@ -1,78 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
if (!e.auth) throw new UnauthorizedError("Authentication is required");
|
||||
utils.validateSubmission(e.record);
|
||||
const user = e.app.findRecordById("users", e.auth.id);
|
||||
e.record.set("author", e.auth.id);
|
||||
e.record.set("authorName", user.getString("displayName"));
|
||||
e.record.set("revision", 1);
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const original = e.app.findRecordById("submissions", e.record.id);
|
||||
e.record.set("author", original.get("author"));
|
||||
e.record.set("authorName", original.get("authorName"));
|
||||
e.record.set("revision", original.getInt("revision") + 1);
|
||||
utils.validateSubmission(e.record);
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordDeleteRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const adminDelete = e.auth && utils.isAdmin(e.app, e.auth.id) && e.record.getString("author") !== e.auth.id;
|
||||
const snapshot = adminDelete ? utils.snapshot(e.record) : "";
|
||||
e.next();
|
||||
if (adminDelete)
|
||||
utils.audit(e.app, e.auth, "delete_submission", {
|
||||
submissionId: e.record.id,
|
||||
reason: "Deleted by Workshop administrator",
|
||||
snapshot,
|
||||
});
|
||||
}, "submissions");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
if (!e.auth) throw new UnauthorizedError("Authentication is required");
|
||||
const value = e.record.getInt("value");
|
||||
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
|
||||
e.record.set("user", e.auth.id);
|
||||
e.next();
|
||||
}, "votes");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const original = e.app.findRecordById("votes", e.record.id);
|
||||
const value = e.record.getInt("value");
|
||||
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
|
||||
e.record.set("user", original.get("user"));
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.next();
|
||||
}, "votes");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
if (!e.auth) throw new UnauthorizedError("Authentication is required");
|
||||
e.record.set("reporter", e.auth.id);
|
||||
e.record.set("status", "open");
|
||||
e.next();
|
||||
}, "reports");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const original = e.app.findRecordById("reports", e.record.id);
|
||||
if (e.record.getString("status") !== "dismissed") throw new BadRequestError("Reports can only be dismissed");
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.record.set("reporter", original.get("reporter"));
|
||||
e.record.set("category", original.get("category"));
|
||||
e.record.set("explanation", original.get("explanation"));
|
||||
e.next();
|
||||
if (original.getString("status") !== "dismissed") {
|
||||
utils.audit(e.app, e.auth, "dismiss_report", {
|
||||
submissionId: original.getString("submission"),
|
||||
reportId: original.id,
|
||||
reason: e.record.getString("dismissalReason") || "Dismissed by Workshop administrator",
|
||||
snapshot: utils.snapshot(original),
|
||||
});
|
||||
}
|
||||
}, "reports");
|
||||
@@ -12,43 +12,27 @@ migrate(
|
||||
if (!users.fields.getByName("displayName"))
|
||||
users.fields.add(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
|
||||
if (!users.fields.getByName("avatarUrl")) users.fields.add(new URLField({ name: "avatarUrl" }));
|
||||
app.save(users);
|
||||
if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
|
||||
|
||||
const admins = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_admins",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
],
|
||||
});
|
||||
admins.addIndex("idx_workshop_admin_user", true, "user", "");
|
||||
app.save(admins);
|
||||
const adminRule = "@collection.workshop_admins.user ?= @request.auth.id";
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
users.listRule = adminRule;
|
||||
users.viewRule = `id = @request.auth.id || ${adminRule}`;
|
||||
users.updateRule = "id = @request.auth.id";
|
||||
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
|
||||
users.deleteRule = null;
|
||||
app.save(users);
|
||||
|
||||
const submissions = new Collection({
|
||||
type: "base",
|
||||
name: "submissions",
|
||||
listRule: adminRule,
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
|
||||
updateRule: "author = @request.auth.id",
|
||||
updateRule: "author = @request.auth.id && @request.body.author:changed = false",
|
||||
deleteRule: `author = @request.auth.id || ${adminRule}`,
|
||||
fields: [
|
||||
{ type: "text", name: "title", required: true, min: 3, max: 100 },
|
||||
{ type: "text", name: "description", max: 2000 },
|
||||
{ type: "text", name: "content", required: true, max: 1000000 },
|
||||
{ type: "text", name: "contentHash", required: true, max: 64 },
|
||||
{
|
||||
type: "file",
|
||||
name: "screenshots",
|
||||
@@ -58,9 +42,6 @@ migrate(
|
||||
thumbs: ["480x320", "960x640"],
|
||||
},
|
||||
{ type: "relation", name: "author", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "text", name: "authorName", required: true, max: 100 },
|
||||
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
|
||||
{ type: "text", name: "changelog", max: 2000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
@@ -74,8 +55,10 @@ migrate(
|
||||
name: "votes",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "@request.auth.id != '' && @request.body.user = @request.auth.id",
|
||||
updateRule: "user = @request.auth.id",
|
||||
createRule:
|
||||
"@request.auth.id != '' && @request.body.user = @request.auth.id && (@request.body.value = 1 || @request.body.value = -1)",
|
||||
updateRule:
|
||||
"user = @request.auth.id && @request.body.user:changed = false && @request.body.submission:changed = false && (@request.body.value = 1 || @request.body.value = -1)",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
fields: [
|
||||
{
|
||||
@@ -102,8 +85,8 @@ migrate(
|
||||
listRule: adminRule,
|
||||
viewRule: `reporter = @request.auth.id || ${adminRule}`,
|
||||
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
|
||||
updateRule: adminRule,
|
||||
deleteRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: adminRule,
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
@@ -129,55 +112,29 @@ migrate(
|
||||
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
|
||||
},
|
||||
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
|
||||
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "dismissed"] },
|
||||
{ type: "text", name: "dismissalReason", max: 1000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
|
||||
reports.addIndex("idx_reports_status_created", false, "status, created", "");
|
||||
reports.addIndex("idx_reports_created", false, "created", "");
|
||||
app.save(reports);
|
||||
|
||||
const actions = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_admin_actions",
|
||||
listRule: adminRule,
|
||||
viewRule: adminRule,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: "relation", name: "actor", required: true, maxSelect: 1, collectionId: users.id },
|
||||
{
|
||||
type: "select",
|
||||
name: "action",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["delete_submission", "dismiss_report"],
|
||||
},
|
||||
{ type: "text", name: "submissionId", max: 30 },
|
||||
{ type: "text", name: "reportId", max: 30 },
|
||||
{ type: "text", name: "reason", max: 1000 },
|
||||
{ type: "text", name: "snapshot", max: 100000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
],
|
||||
});
|
||||
actions.addIndex("idx_workshop_admin_actions_created", false, "created", "");
|
||||
app.save(actions);
|
||||
|
||||
const listings = new Collection({
|
||||
type: "view",
|
||||
name: "workshop_listings",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
viewQuery: `
|
||||
SELECT s.id, s.title, s.description, s.contentHash, s.screenshots,
|
||||
s.revision, s.changelog, s.author, s.authorName, s.created, s.updated,
|
||||
SELECT s.id, s.title, s.description, s.screenshots, s.author, u.displayName AS authorName,
|
||||
s.created, s.updated,
|
||||
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
|
||||
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
|
||||
COALESCE(SUM(v.value), 0) AS score
|
||||
FROM submissions s LEFT JOIN votes v ON v.submission = s.id GROUP BY s.id
|
||||
FROM submissions s
|
||||
JOIN users u ON u.id = s.author
|
||||
LEFT JOIN votes v ON v.submission = s.id
|
||||
GROUP BY s.id
|
||||
`,
|
||||
});
|
||||
app.save(listings);
|
||||
@@ -195,14 +152,7 @@ migrate(
|
||||
app.save(settings);
|
||||
},
|
||||
(app) => {
|
||||
for (const name of [
|
||||
"workshop_listings",
|
||||
"workshop_admin_actions",
|
||||
"reports",
|
||||
"votes",
|
||||
"submissions",
|
||||
"workshop_admins",
|
||||
]) {
|
||||
for (const name of ["workshop_listings", "reports", "votes", "submissions"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {}
|
||||
|
||||
@@ -15,7 +15,10 @@ const expectStatus = async (path, init, expected) => {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
if (response.status !== expected) throw new Error(`Expected ${expected} for ${path}, received ${response.status}`);
|
||||
if (response.status !== expected) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Expected ${expected} for ${path}, received ${response.status}: ${body}`);
|
||||
}
|
||||
};
|
||||
|
||||
const root = await request("/api/collections/_superusers/auth-with-password", {
|
||||
@@ -25,18 +28,14 @@ const root = await request("/api/collections/_superusers/auth-with-password", {
|
||||
const rootHeaders = { authorization: `Bearer ${root.token}` };
|
||||
const collections = await request("/api/collections?perPage=200", { headers: rootHeaders });
|
||||
const collectionNames = new Set(collections.items.map((collection) => collection.name));
|
||||
for (const required of [
|
||||
"submissions",
|
||||
"votes",
|
||||
"reports",
|
||||
"workshop_admins",
|
||||
"workshop_admin_actions",
|
||||
"workshop_listings",
|
||||
]) {
|
||||
for (const required of ["submissions", "votes", "reports", "workshop_listings"]) {
|
||||
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
|
||||
}
|
||||
for (const removed of ["comments", "workshop_admin_actions", "workshop_admins"]) {
|
||||
if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`);
|
||||
}
|
||||
|
||||
// Workshop production authentication is GitHub OAuth. Password auth is enabled only in this disposable test database.
|
||||
// Production uses GitHub OAuth. Password auth is enabled only in this disposable database.
|
||||
const usersCollection = await request("/api/collections/users", { headers: rootHeaders });
|
||||
await request("/api/collections/users", {
|
||||
method: "PATCH",
|
||||
@@ -44,29 +43,42 @@ await request("/api/collections/users", {
|
||||
body: JSON.stringify({ ...usersCollection, passwordAuth: { enabled: true, identityFields: ["email"] } }),
|
||||
});
|
||||
|
||||
const password = "WorkshopAuthor123!";
|
||||
const user = await request("/api/collections/users/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({
|
||||
email: "widget-author@example.invalid",
|
||||
emailVisibility: false,
|
||||
verified: true,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
displayName: "Widget Author",
|
||||
}),
|
||||
});
|
||||
await request("/api/collections/workshop_admins/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ user: user.id }),
|
||||
});
|
||||
const auth = await request("/api/collections/users/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: user.email, password }),
|
||||
});
|
||||
const userHeaders = { authorization: `Bearer ${auth.token}` };
|
||||
const createUser = async (email, displayName, password, extra = {}) =>
|
||||
request("/api/collections/users/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
emailVisibility: false,
|
||||
verified: true,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
displayName,
|
||||
...extra,
|
||||
}),
|
||||
});
|
||||
|
||||
const signIn = async (email, password) => {
|
||||
const auth = await request("/api/collections/users/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: email, password }),
|
||||
});
|
||||
return { auth, headers: { authorization: `Bearer ${auth.token}` } };
|
||||
};
|
||||
|
||||
const authorPassword = "WorkshopAuthor123!";
|
||||
const author = await createUser("widget-author@example.invalid", "Widget Author", authorPassword, { isAdmin: true });
|
||||
if (author.isAdmin !== false) throw new Error("New users must not be able to start as Workshop administrators");
|
||||
const authorSession = await signIn(author.email, authorPassword);
|
||||
await expectStatus(
|
||||
`/api/collections/users/records/${author.id}`,
|
||||
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ isAdmin: true }) },
|
||||
404,
|
||||
);
|
||||
|
||||
const visitorPassword = "WorkshopVisitor123!";
|
||||
const visitor = await createUser("widget-visitor@example.invalid", "Widget Visitor", visitorPassword);
|
||||
const visitorSession = await signIn(visitor.email, visitorPassword);
|
||||
|
||||
const widget = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
@@ -85,163 +97,143 @@ const widget = {
|
||||
defaultOptions: {},
|
||||
template: "<Text>Runtime probe</Text>",
|
||||
};
|
||||
|
||||
const submission = await request("/api/collections/submissions/records", {
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({
|
||||
title: "Workshop runtime probe",
|
||||
description: "PocketBase integration test",
|
||||
content: JSON.stringify(widget),
|
||||
changelog: "Initial",
|
||||
author: user.id,
|
||||
author: author.id,
|
||||
}),
|
||||
});
|
||||
if (submission.revision !== 1 || submission.authorName !== "Widget Author" || submission.contentHash.length !== 64) {
|
||||
throw new Error("Submission hooks did not normalize publication metadata");
|
||||
if (submission.author !== author.id || submission.title !== "Workshop runtime probe") {
|
||||
throw new Error("Submission publication failed");
|
||||
}
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Removed format",
|
||||
content: JSON.stringify({ displayType: "raw", displayConfig: { type: "raw" } }),
|
||||
author: user.id,
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Unsafe authentication header",
|
||||
content: JSON.stringify({
|
||||
...widget,
|
||||
sources: [{ ...widget.sources[0], auth: { type: "apiKeyHeader", headerName: "X-Forwarded-Authorization" } }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Unknown template request",
|
||||
content: JSON.stringify({ ...widget, template: '<SubFetch requestId="missing" />' }),
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
const updatedWidget = { ...widget, description: "Updated definition" };
|
||||
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
|
||||
method: "PATCH",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ content: JSON.stringify(updatedWidget), changelog: "Updated" }),
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ title: "Updated runtime probe" }),
|
||||
});
|
||||
if (updatedSubmission.revision !== 2) throw new Error("Content edits must increment the submission revision");
|
||||
if (updatedSubmission.title !== "Updated runtime probe") throw new Error("Authors must be able to edit their submission");
|
||||
|
||||
await request("/api/collections/votes/records", {
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ author: visitor.id }) },
|
||||
404,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ title: "Not mine" }) },
|
||||
404,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "DELETE", headers: visitorSession.headers },
|
||||
404,
|
||||
);
|
||||
|
||||
const vote = await request("/api/collections/votes/records", {
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ submission: submission.id, user: user.id, value: 1 }),
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({ submission: submission.id, user: visitor.id, value: 1 }),
|
||||
});
|
||||
await expectStatus(
|
||||
"/api/collections/votes/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ submission: submission.id, user: user.id, value: 1 }),
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({ submission: submission.id, user: visitor.id, value: 1 }),
|
||||
},
|
||||
400,
|
||||
);
|
||||
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
|
||||
if (listing.score !== 1 || listing.upvotes !== 1 || listing.downvotes !== 0) {
|
||||
throw new Error("Workshop listing vote totals are incorrect");
|
||||
}
|
||||
|
||||
const report = await request("/api/collections/reports/records", {
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
submission: submission.id,
|
||||
reporter: user.id,
|
||||
category: "other",
|
||||
explanation: "Runtime moderation test",
|
||||
status: "dismissed",
|
||||
}),
|
||||
});
|
||||
const reports = await request("/api/collections/reports/records?filter=status%3D%27open%27", {
|
||||
headers: userHeaders,
|
||||
});
|
||||
if (reports.items.length !== 1 || reports.items[0].reporter !== user.id) {
|
||||
throw new Error("Workshop administrators cannot inspect open reports and reporters");
|
||||
}
|
||||
|
||||
await request(`/api/collections/reports/records/${report.id}`, {
|
||||
method: "PATCH",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ status: "dismissed", dismissalReason: "Runtime test complete" }),
|
||||
});
|
||||
const actions = await request("/api/collections/workshop_admin_actions/records", { headers: userHeaders });
|
||||
if (!actions.items.some((action) => action.action === "dismiss_report" && action.reportId === report.id)) {
|
||||
throw new Error("Workshop report dismissal was not audited");
|
||||
}
|
||||
|
||||
const authorPassword = "WorkshopSecondAuthor123!";
|
||||
const secondAuthor = await request("/api/collections/users/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({
|
||||
email: "second-widget-author@example.invalid",
|
||||
emailVisibility: false,
|
||||
verified: true,
|
||||
password: authorPassword,
|
||||
passwordConfirm: authorPassword,
|
||||
displayName: "Second Author",
|
||||
}),
|
||||
});
|
||||
const secondAuth = await request("/api/collections/users/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: secondAuthor.email, password: authorPassword }),
|
||||
});
|
||||
const secondHeaders = { authorization: `Bearer ${secondAuth.token}` };
|
||||
const secondSubmission = await request("/api/collections/submissions/records", {
|
||||
method: "POST",
|
||||
headers: secondHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Administrator deletion probe",
|
||||
content: JSON.stringify(widget),
|
||||
author: secondAuthor.id,
|
||||
}),
|
||||
});
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "DELETE", headers: secondHeaders },
|
||||
`/api/collections/votes/records/${vote.id}`,
|
||||
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ user: author.id }) },
|
||||
404,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${secondSubmission.id}`,
|
||||
{ method: "DELETE", headers: userHeaders },
|
||||
204,
|
||||
`/api/collections/votes/records/${vote.id}`,
|
||||
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ value: 0 }) },
|
||||
404,
|
||||
);
|
||||
const deletionActions = await request("/api/collections/workshop_admin_actions/records", { headers: userHeaders });
|
||||
if (
|
||||
!deletionActions.items.some(
|
||||
(action) => action.action === "delete_submission" && action.submissionId === secondSubmission.id,
|
||||
)
|
||||
) {
|
||||
throw new Error("Workshop administrator deletion was not audited");
|
||||
|
||||
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
|
||||
if (listing.score !== 1 || listing.upvotes !== 1 || listing.downvotes !== 0 || listing.authorName !== "Widget Author") {
|
||||
throw new Error("Workshop listing data is incorrect");
|
||||
}
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/reports/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({
|
||||
submission: submission.id,
|
||||
reporter: author.id,
|
||||
category: "other",
|
||||
explanation: "Spoofed reporter",
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
const report = await request("/api/collections/reports/records", {
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({
|
||||
submission: submission.id,
|
||||
reporter: visitor.id,
|
||||
category: "other",
|
||||
explanation: "Runtime moderation test",
|
||||
}),
|
||||
});
|
||||
const ownReport = await request(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers });
|
||||
if (ownReport.reporter !== visitor.id) throw new Error("Reporters must be able to view their own report");
|
||||
const hiddenReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
|
||||
if (hiddenReports.items.length !== 0) throw new Error("Regular users must not list moderation reports");
|
||||
await expectStatus(
|
||||
`/api/collections/reports/records/${report.id}`,
|
||||
{ method: "DELETE", headers: visitorSession.headers },
|
||||
404,
|
||||
);
|
||||
|
||||
const promotedAuthor = await request(`/api/collections/users/records/${author.id}`, {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ isAdmin: true }),
|
||||
});
|
||||
if (promotedAuthor.isAdmin !== true) throw new Error("PocketBase superusers must be able to appoint admins");
|
||||
|
||||
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
|
||||
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id) {
|
||||
throw new Error("Workshop administrators must be able to review reports");
|
||||
}
|
||||
await expectStatus(
|
||||
`/api/collections/reports/records/${report.id}`,
|
||||
{ method: "DELETE", headers: authorSession.headers },
|
||||
204,
|
||||
);
|
||||
|
||||
const visitorSubmission = await request("/api/collections/submissions/records", {
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({
|
||||
title: "Administrator deletion probe",
|
||||
content: JSON.stringify(widget),
|
||||
author: visitor.id,
|
||||
}),
|
||||
});
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${visitorSubmission.id}`,
|
||||
{ method: "DELETE", headers: authorSession.headers },
|
||||
204,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "DELETE", headers: authorSession.headers },
|
||||
204,
|
||||
);
|
||||
|
||||
console.log("Workshop PocketBase integration passed");
|
||||
Reference in new issue
Block a user