fix(custom-widgets): finish v2 hardening
This commit is contained in:
97 files changed
+2833
-12393
No files matched your search
@@ -31,9 +31,8 @@ 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
|
||||
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
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data && chown -R pocketbase:pocketbase /pb_data /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:${PORT}/api/health" || exit 1
|
||||
|
||||
+11
-4
@@ -3,7 +3,7 @@
|
||||
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per 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.
|
||||
collections, API rules, 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;
|
||||
@@ -20,8 +20,9 @@ container. With the image directly, set both values: `docker run -e PORT=3003 -p
|
||||
|
||||
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.
|
||||
The `users.isAdmin` field defaults to `false`. Collection rules allow account creation only through OAuth without that
|
||||
field and reject regular-user updates to it. 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.
|
||||
|
||||
@@ -39,8 +40,13 @@ Run the disposable service integration test from the repository root:
|
||||
pnpm test:workshop
|
||||
```
|
||||
|
||||
The test uses a separate Compose project and deletes its named volume on exit. It never writes fixture users or admin
|
||||
roles into the normal `homarr-workshop_pb_data` volume.
|
||||
|
||||
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.
|
||||
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
|
||||
manifest before Homarr enables 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.
|
||||
@@ -53,7 +59,8 @@ Build the documentation and serve it from PocketBase using the same production i
|
||||
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.
|
||||
Open `http://127.0.0.1:3003`. Set `DOCS_EXPOSE_PORT` to use another host port. PocketBase data persists in the
|
||||
`homarr-workshop_pb_data` named volume.
|
||||
|
||||
Point a local Homarr checkout at it with:
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ services:
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
|
||||
volumes:
|
||||
- pb_data:/pb_data
|
||||
- ./pb_hooks:/pb_hooks:ro
|
||||
- ./pb_migrations:/pb_migrations:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@@ -5,7 +5,6 @@ exec pocketbase serve \
|
||||
--http="0.0.0.0:${PORT:-8090}" \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-*}" \
|
||||
--dir=/pb_data \
|
||||
--hooksDir=/pb_hooks \
|
||||
--migrationsDir=/pb_migrations \
|
||||
--publicDir=/pb_public \
|
||||
--indexFallback=true
|
||||
@@ -1,6 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordCreate((e) => {
|
||||
e.record.set("isAdmin", false);
|
||||
e.next();
|
||||
}, "users");
|
||||
@@ -17,6 +17,7 @@ migrate(
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
users.listRule = adminRule;
|
||||
users.viewRule = `id = @request.auth.id || ${adminRule}`;
|
||||
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
|
||||
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
|
||||
users.deleteRule = null;
|
||||
app.save(users);
|
||||
@@ -32,6 +33,7 @@ migrate(
|
||||
fields: [
|
||||
{ type: "text", name: "title", required: true, min: 3, max: 100 },
|
||||
{ type: "text", name: "description", max: 2000 },
|
||||
{ type: "text", name: "widgetSchema", required: true, min: 1, max: 100 },
|
||||
{ type: "text", name: "content", required: true, max: 1000000 },
|
||||
{
|
||||
type: "file",
|
||||
@@ -126,7 +128,7 @@ migrate(
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
viewQuery: `
|
||||
SELECT s.id, s.title, s.description, s.screenshots, s.author, u.displayName AS authorName,
|
||||
SELECT s.id, s.title, s.description, s.widgetSchema, 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,
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
WORKSHOP_TEST_DATA_DIR=$(mktemp -d)
|
||||
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
|
||||
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
|
||||
|
||||
cleanup() {
|
||||
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
|
||||
docker compose -f apps/workshop/docker-compose.yml down --remove-orphans >/dev/null 2>&1 || true
|
||||
rm -rf "$WORKSHOP_TEST_DATA_DIR"
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
|
||||
docker compose -f apps/workshop/docker-compose.yml up --build -d
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml up --build -d
|
||||
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 60 ]; then
|
||||
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
|
||||
docker compose -f apps/workshop/docker-compose.yml logs --no-color workshop
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml logs --no-color workshop
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker exec homarr-workshop-workshop-1 pocketbase superuser create \
|
||||
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml exec -T workshop \
|
||||
pocketbase superuser create \
|
||||
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
|
||||
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
|
||||
@@ -37,6 +37,9 @@ for (const removed of ["comments", "workshop_admin_actions", "workshop_admins"])
|
||||
|
||||
// Production uses GitHub OAuth. Password auth is enabled only in this disposable database.
|
||||
const usersCollection = await request("/api/collections/users", { headers: rootHeaders });
|
||||
if (usersCollection.createRule !== '@request.context = "oauth2" && @request.body.isAdmin:isset = false') {
|
||||
throw new Error("User creation must be limited to OAuth without administrator fields");
|
||||
}
|
||||
await request("/api/collections/users", {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
@@ -67,8 +70,7 @@ const signIn = async (email, password) => {
|
||||
};
|
||||
|
||||
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 author = await createUser("widget-author@example.invalid", "Widget Author", authorPassword);
|
||||
const authorSession = await signIn(author.email, authorPassword);
|
||||
await expectStatus(
|
||||
`/api/collections/users/records/${author.id}`,
|
||||
@@ -104,11 +106,16 @@ const submission = await request("/api/collections/submissions/records", {
|
||||
body: JSON.stringify({
|
||||
title: "Workshop runtime probe",
|
||||
description: "PocketBase integration test",
|
||||
widgetSchema: widget.$schema,
|
||||
content: JSON.stringify(widget),
|
||||
author: author.id,
|
||||
}),
|
||||
});
|
||||
if (submission.author !== author.id || submission.title !== "Workshop runtime probe") {
|
||||
if (
|
||||
submission.author !== author.id ||
|
||||
submission.title !== "Workshop runtime probe" ||
|
||||
submission.widgetSchema !== widget.$schema
|
||||
) {
|
||||
throw new Error("Submission publication failed");
|
||||
}
|
||||
|
||||
@@ -117,7 +124,8 @@ const updatedSubmission = await request(`/api/collections/submissions/records/${
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ title: "Updated runtime probe" }),
|
||||
});
|
||||
if (updatedSubmission.title !== "Updated runtime probe") throw new Error("Authors must be able to edit their submission");
|
||||
if (updatedSubmission.title !== "Updated runtime probe")
|
||||
throw new Error("Authors must be able to edit their submission");
|
||||
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
@@ -161,7 +169,13 @@ await expectStatus(
|
||||
);
|
||||
|
||||
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") {
|
||||
if (
|
||||
listing.score !== 1 ||
|
||||
listing.upvotes !== 1 ||
|
||||
listing.downvotes !== 0 ||
|
||||
listing.authorName !== "Widget Author" ||
|
||||
listing.widgetSchema !== widget.$schema
|
||||
) {
|
||||
throw new Error("Workshop listing data is incorrect");
|
||||
}
|
||||
|
||||
@@ -222,6 +236,7 @@ const visitorSubmission = await request("/api/collections/submissions/records",
|
||||
body: JSON.stringify({
|
||||
title: "Administrator deletion probe",
|
||||
content: JSON.stringify(widget),
|
||||
widgetSchema: widget.$schema,
|
||||
author: visitor.id,
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user