Restore Workshop and preserve Custom Widget docs
This commit is contained in:
208 files changed
+9503
-3337
No files matched your search
@@ -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
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
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 {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user