Restore Workshop and preserve Custom Widget docs

This commit is contained in:
Thomas Camlong committed 2026-07-21 11:48:51 +02:00
1 parent 2c932f1181
commit 09e653c36f
208 files changed
+9503 -3337

No files matched your search

+2 -1
View File
@@ -32,6 +32,7 @@ HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget
FROM pocketbase-runtime AS production
COPY --from=website /app/apps/docs/build /pb_public
COPY apps/workshop/pb_migrations /pb_migrations
RUN chown -R pocketbase:pocketbase /pb_migrations /pb_public
COPY apps/workshop/pb_hooks /pb_hooks
RUN chown -R pocketbase:pocketbase /pb_hooks /pb_migrations /pb_public
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
+23 -9
View File
@@ -5,24 +5,27 @@ Workshop is a small PocketBase service that serves the public documentation site
The runtime image extends `ghcr.io/muchobien/pocketbase:0.39`; Homarr adds only the built documentation, PocketBase
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;
users with `isAdmin` enabled can inspect and dismiss reports or delete any submission.
It intentionally has no approval queue, user bans, moderator roles, reusable widget connections, or remote control over
installed widgets. Submissions publish immediately. Authenticated users can vote, comment, and report; users with
`isAdmin` enabled can dismiss reports or delete any submission or comment.
## Local development
```sh
PB_EXPOSE_PORT=18090 docker compose -f apps/workshop/docker-compose.yml up --build
pnpm docker:workshop
```
Use `docker compose -f apps/workshop/docker-compose.yml logs -f workshop` for logs and
`docker compose -f apps/workshop/docker-compose.yml stop workshop` to stop it without deleting data.
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`. 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.
field and reject all regular-user attempts to change it. Appoint administrators out of band with a PocketBase
superuser or a deliberate SQLite update; there is no public role-management endpoint or hook.
Users must sign in again after the role changes. Setting the value back to `false` removes Workshop administrator access.
@@ -34,6 +37,10 @@ https://<workshop-host>/api/oauth2-redirect
Self-hosted Homarr origins do not need to be registered with GitHub. Keep `PB_ALLOWED_ORIGINS=*` (the default) so localhost and arbitrary self-hosted domains can open the central PocketBase OAuth popup. Set a restricted origin list only if it includes every Homarr origin that should use Workshop sign-in.
Set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` in the repository-root `.env`. Set
`WORKSHOP_PUBLIC_ORIGIN=https://<workshop-host>` so new-comment emails link to the public submission page. Configure the
sender and SMTP transport in PocketBase. Comment email failures are logged and never reject a saved comment.
Run the disposable service integration test from the repository root:
```sh
@@ -47,9 +54,10 @@ The Workshop does not maintain a second Custom Widget schema. Its frontend valid
`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.
PocketBase API rules enforce submission and comment ownership, one vote/report per user and submission, and
administrator-only moderation. Each new submission receives one real author-owned upvote. Reports remain public and are
dismissed by changing their status. PocketBase cascade deletion removes related votes, comments, and reports when a
submission is deleted. Authors and administrators can mark a submission outdated without making it un-installable.
## Production docs preview
@@ -80,5 +88,11 @@ This command starts the profiled `docs` service from `apps/workshop/docker-compo
docker compose -f apps/workshop/docker-compose.yml --profile docs rm --stop --force docs
```
Normal startup never deletes data. To intentionally recreate the local database, stop the stack first and then run:
```sh
docker compose -f apps/workshop/docker-compose.yml down --volumes
```
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`.
At startup the container repairs ownership of existing data volumes, then runs PocketBase as its non-root user.
+7
View File
@@ -10,10 +10,14 @@ services:
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${PB_EXPOSE_PORT:-8090}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:8090}
ports:
- "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
@@ -28,6 +32,9 @@ services:
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${DOCS_EXPOSE_PORT:-3003}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
ports:
- "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:${DOCS_EXPOSE_PORT:-3003}"
volumes:
+1
View File
@@ -10,6 +10,7 @@ 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
+55
View File
@@ -0,0 +1,55 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordAfterCreateSuccess((event) => {
try {
const votes = event.app.findCollectionByNameOrId("votes");
const vote = new Record(votes);
vote.set("submission", event.record.id);
vote.set("user", event.record.get("author"));
vote.set("value", 1);
event.app.save(vote);
} catch (error) {
console.log(`Workshop initial upvote failed for ${event.record.id}: ${error}`);
}
event.next();
}, "submissions");
const escapeHtml = (value) =>
String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
onRecordAfterCreateSuccess((event) => {
try {
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
const recipient = event.app.findRecordById("users", submission.get("author"));
const commenter = event.app.findRecordById("users", event.record.get("author"));
const recipientEmail = recipient.email();
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
if (!recipientEmail || recipient.id === commenter.id || !publicOrigin) {
event.next();
return;
}
const commenterName = commenter.getString("displayName") || "A community member";
const submissionTitle = submission.getString("title");
const rawExcerpt = event.record.getString("content").slice(0, 280);
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
const sender = event.app.settings().meta;
const message = new MailerMessage({
from: { address: sender.senderAddress, name: sender.senderName },
to: [{ address: recipientEmail }],
subject: `${commenterName} commented on ${submissionTitle}`,
text: `${commenterName} commented on “${submissionTitle}”:\n\n${rawExcerpt}\n\n${submissionUrl}`,
html: `<p><strong>${escapeHtml(commenterName)}</strong> commented on <strong>${escapeHtml(submissionTitle)}</strong>:</p><blockquote>${escapeHtml(rawExcerpt)}</blockquote><p><a href="${escapeHtml(submissionUrl)}">View the comment</a></p>`,
});
event.app.newMailClient().send(message);
} catch (error) {
console.log(`Workshop comment email failed for ${event.record.id}: ${error}`);
}
event.next();
}, "comments");
@@ -8,15 +8,35 @@ migrate(
} catch {
users = new Collection({ type: "auth", name: "users" });
}
users.passwordAuth = { enabled: false };
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" }));
if (!users.fields.getByName("avatar"))
users.fields.add(
new FileField({
name: "avatar",
maxSelect: 1,
maxSize: 2_097_152,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
}),
);
if (!users.fields.getByName("githubUsername"))
users.fields.add(new TextField({ name: "githubUsername", max: 100 }));
if (!users.fields.getByName("githubProfileUrl")) users.fields.add(new URLField({ name: "githubProfileUrl" }));
if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
if (githubClientId && githubClientSecret) {
users.oauth2.enabled = true;
users.oauth2.providers = [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }];
}
const adminRule = "@request.auth.isAdmin = true";
users.listRule = adminRule;
users.viewRule = `id = @request.auth.id || ${adminRule}`;
users.listRule = "";
users.viewRule = "";
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
users.deleteRule = null;
@@ -28,13 +48,17 @@ migrate(
listRule: "",
viewRule: "",
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
updateRule: "author = @request.auth.id && @request.body.author:changed = false",
updateRule: `(author = @request.auth.id || ${adminRule}) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false`,
deleteRule: `author = @request.auth.id || ${adminRule}`,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["customWidget", "customCss"] },
{ 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: "number", name: "revision", required: true, onlyInt: true, min: 1 },
{ type: "text", name: "changelog", max: 2000 },
{ type: "bool", name: "outdated" },
{
type: "file",
name: "screenshots",
@@ -81,13 +105,40 @@ migrate(
votes.addIndex("idx_votes_submission", false, "submission", "");
app.save(votes);
const comments = new Collection({
type: "base",
name: "comments",
listRule: "",
viewRule: "",
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
updateRule:
"author = @request.auth.id && @request.body.author:changed = false && @request.body.submission:changed = false",
deleteRule: `author = @request.auth.id || ${adminRule}`,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ type: "relation", name: "author", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "text", name: "content", required: true, min: 1, max: 2000 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
comments.addIndex("idx_comments_submission", false, "submission", "");
app.save(comments);
const reports = new Collection({
type: "base",
name: "reports",
listRule: adminRule,
viewRule: `reporter = @request.auth.id || ${adminRule}`,
listRule: "",
viewRule: "",
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
updateRule: null,
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
deleteRule: adminRule,
fields: [
{
@@ -114,6 +165,7 @@ 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: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
@@ -128,15 +180,17 @@ migrate(
listRule: "",
viewRule: "",
viewQuery: `
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,
COALESCE(SUM(v.value), 0) AS score
SELECT s.id, s.type, s.title, s.description, s.widgetSchema, s.screenshots, s.author,
u.displayName AS authorName, u.avatarUrl AS authorAvatarUrl,
u.githubUsername AS authorGithubUsername, u.githubProfileUrl AS authorGithubProfileUrl,
s.revision, s.changelog, s.outdated, s.created, s.updated,
COALESCE((SELECT COUNT(*) FROM votes v WHERE v.submission = s.id AND v.value = 1), 0) AS upvotes,
COALESCE((SELECT COUNT(*) FROM votes v WHERE v.submission = s.id AND v.value = -1), 0) AS downvotes,
COALESCE((SELECT SUM(v.value) FROM votes v WHERE v.submission = s.id), 0) AS score,
COALESCE((SELECT COUNT(*) FROM comments c WHERE c.submission = s.id), 0) AS commentCount,
COALESCE((SELECT COUNT(*) FROM reports r WHERE r.submission = s.id AND r.status = 'open'), 0) AS reportCount
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);
@@ -145,16 +199,17 @@ migrate(
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter(
(rule) => !["submissions:create", "votes:create", "reports:create"].includes(rule.label),
(rule) => !["submissions:create", "votes:create", "comments:create", "reports:create"].includes(rule.label),
),
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
];
app.save(settings);
},
(app) => {
for (const name of ["workshop_listings", "reports", "votes", "submissions"]) {
for (const name of ["workshop_listings", "reports", "comments", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {}
+64 -11
View File
@@ -28,10 +28,10 @@ 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_listings"]) {
for (const required of ["submissions", "votes", "comments", "reports", "workshop_listings"]) {
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
}
for (const removed of ["comments", "workshop_admin_actions", "workshop_admins"]) {
for (const removed of ["workshop_admin_actions", "workshop_admins"]) {
if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`);
}
@@ -102,11 +102,15 @@ const submission = await request("/api/collections/submissions/records", {
method: "POST",
headers: authorSession.headers,
body: JSON.stringify({
type: "customWidget",
title: "Workshop runtime probe",
description: "PocketBase integration test",
widgetSchema: widget.$schema,
content: JSON.stringify(widget),
author: author.id,
revision: 1,
changelog: "Initial publication",
outdated: false,
}),
});
if (
@@ -116,6 +120,14 @@ if (
) {
throw new Error("Submission publication failed");
}
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
if (
ownerVotes.items.length !== 1 ||
ownerVotes.items[0].submission !== submission.id ||
ownerVotes.items[0].value !== 1
) {
throw new Error("New submissions must receive one real author-owned upvote");
}
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
@@ -124,6 +136,14 @@ const updatedSubmission = await request(`/api/collections/submissions/records/${
});
if (updatedSubmission.title !== "Updated runtime probe")
throw new Error("Authors must be able to edit their submission");
const outdatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ outdated: true, revision: 2, changelog: "Needs a newer API" }),
});
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 2) {
throw new Error("Authors must be able to mark submissions outdated and increment revisions");
}
await expectStatus(
`/api/collections/submissions/records/${submission.id}`,
@@ -168,8 +188,8 @@ await expectStatus(
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
if (
listing.score !== 1 ||
listing.upvotes !== 1 ||
listing.score !== 2 ||
listing.upvotes !== 2 ||
listing.downvotes !== 0 ||
listing.authorName !== "Widget Author" ||
listing.widgetSchema !== widget.$schema
@@ -177,6 +197,26 @@ if (
throw new Error("Workshop listing data is incorrect");
}
const comment = await request("/api/collections/comments/records?expand=author", {
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({ submission: submission.id, author: visitor.id, content: "Useful widget" }),
});
if (comment.author !== visitor.id || comment.expand.author.displayName !== "Widget Visitor") {
throw new Error("Comment author expansion is incorrect");
}
await expectStatus(
`/api/collections/comments/records/${comment.id}`,
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ content: "Not mine" }) },
404,
);
const editedComment = await request(`/api/collections/comments/records/${comment.id}`, {
method: "PATCH",
headers: visitorSession.headers,
body: JSON.stringify({ content: "Useful updated widget" }),
});
if (editedComment.content !== "Useful updated widget") throw new Error("Comment editing failed");
await expectStatus(
"/api/collections/reports/records",
{
@@ -187,6 +227,7 @@ await expectStatus(
reporter: author.id,
category: "other",
explanation: "Spoofed reporter",
status: "open",
}),
},
400,
@@ -199,12 +240,13 @@ const report = await request("/api/collections/reports/records", {
reporter: visitor.id,
category: "other",
explanation: "Runtime moderation test",
status: "open",
}),
});
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");
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
if (publicReports.items.length !== 1) throw new Error("Report details must remain publicly visible");
await expectStatus(
`/api/collections/reports/records/${report.id}`,
{ method: "DELETE", headers: visitorSession.headers },
@@ -218,24 +260,35 @@ const promotedAuthor = await request(`/api/collections/users/records/${author.id
});
if (promotedAuthor.isAdmin !== true) throw new Error("PocketBase superusers must be able to appoint admins");
await expectStatus(
`/api/collections/comments/records/${comment.id}`,
{ method: "DELETE", headers: authorSession.headers },
204,
);
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 dismissedReport = await request(`/api/collections/reports/records/${report.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ status: "dismissed" }),
});
if (dismissedReport.status !== "dismissed") throw new Error("Workshop administrators must be able to dismiss reports");
const visitorSubmission = await request("/api/collections/submissions/records", {
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customWidget",
title: "Administrator deletion probe",
content: JSON.stringify(widget),
widgetSchema: widget.$schema,
author: visitor.id,
revision: 1,
changelog: "Initial publication",
outdated: false,
}),
});
await expectStatus(