fix: harden workshop availability

This commit is contained in:
Thomas Camlong committed 2026-07-14 01:21:53 +02:00
1 parent 699134a576
commit 31e2e5ac7f
28 files changed
+671 -279

No files matched your search

+2 -2
View File
@@ -19,13 +19,13 @@ RUN case "${TARGETARCH}" in \
FROM alpine:3.22
RUN apk add --no-cache ca-certificates su-exec tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
RUN apk add --no-cache ca-certificates tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=download /out/pocketbase /usr/local/bin/pocketbase
COPY entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_hooks /pb_migrations && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations
USER root
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
ENTRYPOINT ["workshop-entrypoint"]
+3 -3
View File
@@ -5,17 +5,17 @@ PocketBase provides the central Workshop API at `workshop.homarr.dev`. It is not
## Local startup
1. Copy the repository-root `.env.example` to `.env` and create a GitHub OAuth app.
2. Set its callback URL to `http://localhost:8090/api/oauth2-redirect`.
2. Set its callback URL to `http://localhost:<PB_EXPOSE_PORT>/api/oauth2-redirect` (port `8090` by default).
3. Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PB_SUPERUSER_EMAIL`, and `PB_SUPERUSER_PASSWORD`.
4. Run `pnpm dev:workshop` from the repository root. It starts PocketBase and the Docusaurus Workshop together and removes obsolete Compose containers.
Open the Workshop at `http://localhost:3003/workshop`. The API is at `http://localhost:8090`; the emergency PocketBase dashboard is at `http://localhost:8090/_/`. `0.0.0.0` in PocketBase's server log is its container bind address, not a browser URL. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
Open the Workshop at `http://localhost:3003/workshop`. The API is at `http://localhost:<PB_EXPOSE_PORT>` and the emergency PocketBase dashboard is at `http://localhost:<PB_EXPOSE_PORT>/_/` (port `8090` by default). `0.0.0.0` in PocketBase's server log is its container bind address, not a browser URL. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
Use `pnpm dev:workshop:backend` when only the API is needed. All Workshop configuration is read from the repository-root `.env`; there is no app-specific environment file. If port 8090 is unavailable, set `PB_EXPOSE_PORT` there. The website derives its local API URL from that port unless `WORKSHOP_API_URL` is explicitly set.
The image downloads the official PocketBase `0.39.6` release and verifies it against that release's `checksums.txt`. Migrations and hooks are mounted read-only.
GitHub OAuth configuration is synchronized from the environment on every boot, including for an existing development volume. Local CORS is restricted to the Docusaurus origins in `PB_ALLOWED_ORIGINS`; production must set it to `https://homarr.dev`.
GitHub OAuth configuration is synchronized from the environment on every boot, including for an existing development volume. Local CORS is restricted to the documented Homarr and Docusaurus origins in `PB_ALLOWED_ORIGINS`; production must set it to `https://homarr.dev`.
## Roles and account states
+1 -1
View File
@@ -14,7 +14,7 @@ services:
PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:-}
PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:-}
PB_PUBLIC_URL: http://localhost:${PB_EXPOSE_PORT:-8090}
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-http://localhost:3003,http://127.0.0.1:3003}
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000,http://localhost:3003,http://127.0.0.1:3003,http://localhost:7575,http://127.0.0.1:7575}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
volumes:
+3 -5
View File
@@ -1,18 +1,16 @@
#!/bin/sh
set -eu
chown -R pocketbase:pocketbase /pb_data
if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then
su-exec pocketbase pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
fi
echo "Workshop API: ${PB_PUBLIC_URL:-http://localhost:8090}"
echo "Workshop website: http://localhost:3003/workshop"
exec su-exec pocketbase pocketbase serve \
exec pocketbase serve \
--http=0.0.0.0:8090 \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--origins="${PB_ALLOWED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000,http://localhost:3003,http://127.0.0.1:3003,http://localhost:7575,http://127.0.0.1:7575}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations
+6 -1
View File
@@ -69,7 +69,12 @@ function validateSubmission(record) {
}
function snapshot(record) {
return JSON.stringify(record.publicExport());
const exported = record.publicExport();
if (typeof exported.content === "string") {
exported.contentLength = exported.content.length;
delete exported.content;
}
return JSON.stringify(exported);
}
function requireStaff(app, auth) {
@@ -9,13 +9,13 @@ migrate(
users = new Collection({
type: "auth",
name: "users",
listRule: "@request.auth.role = 'moderator' || @request.auth.role = 'admin'",
viewRule: "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'",
updateRule: "id = @request.auth.id && @request.auth.state != 'disabled'",
deleteRule: null,
passwordAuth: { enabled: false },
});
}
users.listRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.updateRule = "id = @request.auth.id && @request.auth.state != 'disabled'";
users.deleteRule = null;
users.passwordAuth = { enabled: false };
// PocketBase creates a default users auth collection on first boot, so
// Workshop fields must be added to the existing collection explicitly.
users.fields.add(
@@ -216,14 +216,22 @@ migrate(
app.save(listings);
const settings = app.settings();
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
{ label: "*:auth", audience: "", duration: 60, maxRequests: 10 },
const workshopRateLimitLabels = [
"submissions:create",
"votes:create",
"reports:create",
"/api/workshop/moderation/",
];
const workshopRateLimits = [
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
{ label: "/api/workshop/moderation/", audience: "@auth", duration: 60, maxRequests: 30 },
{ label: "/api/", audience: "", duration: 10, maxRequests: 300 },
];
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter((rule) => !workshopRateLimitLabels.includes(rule.label)),
...workshopRateLimits,
];
settings.logs.maxDays = 30;
settings.logs.logAuthId = true;
@@ -241,8 +249,15 @@ migrate(
}
}
const settings = app.settings();
settings.rateLimits.enabled = false;
settings.rateLimits.rules = [];
const workshopRateLimitLabels = [
"submissions:create",
"votes:create",
"reports:create",
"/api/workshop/moderation/",
];
settings.rateLimits.rules = settings.rateLimits.rules.filter(
(rule) => !workshopRateLimitLabels.includes(rule.label),
);
app.save(settings);
},
);
+1 -1
View File
@@ -4,7 +4,7 @@ set -eu
project="homarr-workshop-integration"
port="${WORKSHOP_TEST_PORT:-18091}"
email="integration@homarr.test"
password="WorkshopIntegrationPassword123!"
password="${WORKSHOP_TEST_PASSWORD:-WorkshopIntegrationPassword123!}"
cleanup() {
PB_EXPOSE_PORT="$port" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --volumes >/dev/null 2>&1 || true
+19 -5
View File
@@ -11,6 +11,7 @@ const root = new PocketBase(baseUrl);
await root.collection("_superusers").authWithPassword(superuserEmail, superuserPassword);
const authMethods = await root.collection("users").listAuthMethods();
assert.equal(authMethods.oauth2.enabled, true, "GitHub OAuth must be configured from the runtime environment");
assert.equal(authMethods.password.enabled, false, "password authentication must remain disabled");
assert.deepEqual(
authMethods.oauth2.providers.map((provider) => provider.name),
["github"],
@@ -48,6 +49,8 @@ const disabled = await root.collection("users").impersonate(disabledRecord.id, 3
const moderator = await root.collection("users").impersonate(moderatorRecord.id, 3600);
const admin = await root.collection("users").impersonate(adminRecord.id, 3600);
assert.equal((await member.collection("users").getList()).totalItems, 0, "members must not list Workshop accounts");
const forgedMember = await member.collection("users").update(memberRecord.id, {
role: "admin",
state: "posting_banned",
@@ -63,11 +66,20 @@ await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
});
const submission = await member.collection("submissions").create({
type: "css",
title: "Integration theme",
type: "widget",
title: "Integration widget",
description: "A backend integration fixture",
schemaVersion: "forged-version",
content: ":root { --homarr-test: #fff; }",
content: JSON.stringify({
$schema: "homarr-custom-widget-v2",
name: "Integration widget",
url: "https://example.com/status",
authType: "none",
method: "GET",
displayType: "raw",
displayConfig: { type: "raw" },
integrationTestPadding: "x".repeat(150_000),
}),
author: secondMemberRecord.id,
authorName: "Forged",
revision: 99,
@@ -75,7 +87,7 @@ const submission = await member.collection("submissions").create({
assert.equal(submission.author, memberRecord.id, "server must enforce ownership");
assert.equal(submission.authorName, "Member", "server must snapshot the authenticated author name");
assert.equal(submission.revision, 1, "server must own the initial revision");
assert.equal(submission.schemaVersion, "homarr-custom-css-v1", "server must own schemaVersion");
assert.equal(submission.schemaVersion, "homarr-custom-widget-v2", "server must own schemaVersion");
await expectStatus(
() => banned.collection("submissions").create({ type: "css", title: "Blocked theme", content: "body {}" }),
@@ -161,7 +173,9 @@ assert.deepEqual(
actions.map((action) => action.action),
["set_account_state", "set_role", "resolve_report", "remove_submission"],
);
assert(actions.at(-1)?.snapshot.includes("Integration theme"), "removal audit must retain a target snapshot");
assert(actions.at(-1)?.snapshot.includes("Integration widget"), "removal audit must retain a target snapshot");
assert(!actions.at(-1)?.snapshot.includes("integrationTestPadding"), "removal audit must omit large source content");
assert(actions.at(-1)?.snapshot.includes('"contentLength"'), "removal audit must retain the source length");
const anonymous = new PocketBase(baseUrl);
const listings = await anonymous.collection("workshop_listings").getList();