feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
1 parent
3a1245cbc5
commit
acdfcbb18e
416 files changed
+37853
-18066
No files matched your search
@@ -1,3 +1 @@
|
||||
pb_data/
|
||||
.env
|
||||
.env.local
|
||||
+30
-46
@@ -1,54 +1,38 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
|
||||
FROM node:24.18.0-alpine AS docs
|
||||
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39
|
||||
|
||||
FROM node:24.18.0-alpine AS dependencies
|
||||
WORKDIR /app
|
||||
ENV COREPACK_HOME=/corepack
|
||||
RUN mkdir -p /corepack && corepack enable pnpm && corepack install --global pnpm@11.11.0
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
|
||||
COPY patches ./patches
|
||||
COPY apps/docs ./apps/docs
|
||||
COPY packages/custom-widgets ./packages/custom-widgets
|
||||
COPY packages/definitions ./packages/definitions
|
||||
COPY packages/workshop ./packages/workshop
|
||||
COPY tooling/typescript ./tooling/typescript
|
||||
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set store-dir /pnpm/store && pnpm install --filter @homarr/docs... --frozen-lockfile
|
||||
|
||||
RUN corepack enable
|
||||
FROM dependencies AS website
|
||||
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=workshop-pnpm,target=/pnpm/store \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
pnpm install --frozen-lockfile --filter @homarr/docs...
|
||||
RUN WORKSHOP_URL="" pnpm --filter @homarr/docs build
|
||||
FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime
|
||||
ENV PORT=8090
|
||||
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data
|
||||
ENTRYPOINT ["workshop-entrypoint"]
|
||||
|
||||
FROM alpine:3.22 AS pocketbase
|
||||
ARG POCKETBASE_VERSION=0.39.6
|
||||
ARG TARGETARCH
|
||||
|
||||
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 ;; \
|
||||
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; 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}" --output "/tmp/${ARCHIVE}" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
|
||||
cd /tmp && \
|
||||
grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
|
||||
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
|
||||
|
||||
FROM alpine:3.22 AS runtime
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
COPY --from=docs /app/apps/docs/build /pb_public
|
||||
COPY apps/workshop/pb_hooks /pb_hooks
|
||||
COPY apps/workshop/pb_migrations /pb_migrations
|
||||
|
||||
RUN addgroup -S workshop && \
|
||||
adduser -S -G workshop -h /home/workshop workshop && \
|
||||
mkdir -p /pb_data && \
|
||||
chown -R workshop:workshop /pb_data /pb_public /pb_hooks /pb_migrations
|
||||
|
||||
USER workshop
|
||||
FROM pocketbase-runtime AS development
|
||||
EXPOSE 8090
|
||||
VOLUME ["/pb_data"]
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=10 \
|
||||
CMD wget --quiet --spider http://127.0.0.1:8090/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/pocketbase"]
|
||||
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--hooksDir=/pb_hooks", "--migrationsDir=/pb_migrations", "--indexFallback=true"]
|
||||
FROM pocketbase-runtime AS production
|
||||
COPY --from=website /app/apps/docs/build /pb_public
|
||||
COPY apps/workshop/pb_migrations /pb_migrations
|
||||
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
|
||||
@@ -1,15 +1,7 @@
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/.docusaurus
|
||||
**/.next
|
||||
**/.turbo
|
||||
**/build
|
||||
**/coverage
|
||||
**/dist
|
||||
.next
|
||||
.turbo
|
||||
**/node_modules
|
||||
**/pb_data
|
||||
.pnpm-store
|
||||
e2e
|
||||
apps/docs/build
|
||||
coverage
|
||||
+64
-43
@@ -1,80 +1,101 @@
|
||||
# Homarr Workshop
|
||||
|
||||
Workshop ships as one container. Its build stage compiles the current Docusaurus site, and its runtime stage serves those static files and the PocketBase API from the same origin.
|
||||
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per submission.
|
||||
|
||||
## Local production-like run
|
||||
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.
|
||||
|
||||
Configure optional values in the repository-root `.env`, then run:
|
||||
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
|
||||
pnpm dev:workshop
|
||||
pnpm docker:workshop
|
||||
```
|
||||
|
||||
Open:
|
||||
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.
|
||||
|
||||
- documentation: `http://localhost:8090/`;
|
||||
- Workshop: `http://localhost:8090/workshop/`;
|
||||
- PocketBase API: `http://localhost:8090/api/`;
|
||||
- PocketBase dashboard: `http://localhost:8090/_/`.
|
||||
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>`.
|
||||
|
||||
The `homarr_workshop_deployment_data` volume persists records and uploaded files across image rebuilds. Migrations run automatically when PocketBase starts. It is intentionally separate from volumes created by earlier prototypes with incompatible schemas.
|
||||
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, and configure GitHub OAuth on the `users` collection.
|
||||
|
||||
## GitHub OAuth
|
||||
The `users.isAdmin` field defaults to `false`. Collection rules allow account creation only through OAuth without that
|
||||
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.
|
||||
|
||||
Create a GitHub OAuth application with these local values:
|
||||
Users must sign in again after the role changes. Setting the value back to `false` removes Workshop administrator access.
|
||||
|
||||
- homepage: `http://localhost:8090`;
|
||||
- callback: `http://localhost:8090/api/oauth2-redirect`.
|
||||
The GitHub OAuth application needs one callback for the Workshop host:
|
||||
|
||||
Set these values only in the repository-root `.env`:
|
||||
|
||||
```dotenv
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
```text
|
||||
https://<workshop-host>/api/oauth2-redirect
|
||||
```
|
||||
|
||||
For production, use `https://workshop.homarr.dev` and `https://workshop.homarr.dev/api/oauth2-redirect`. Create the first PocketBase superuser through the installer link printed on first startup or through the PocketBase CLI.
|
||||
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.
|
||||
|
||||
## Local image validation
|
||||
Set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` in the repository-root `.env`. Set
|
||||
`WORKSHOP_PUBLIC_ORIGIN=https://<workshop-host>` so notification emails link to the public Workshop. Configure the sender
|
||||
and SMTP transport in PocketBase. PocketBase emails the submission author about new comments, emails every current
|
||||
Workshop administrator about a new report, and emails the author after an administrator deletes their submission. Mail
|
||||
failures are logged and never reject a comment, report, or deletion.
|
||||
|
||||
Build and test the exact runtime shape:
|
||||
Run the disposable service integration test from the repository root:
|
||||
|
||||
```sh
|
||||
docker build -f apps/workshop/Dockerfile -t homarr/workshop:ci .
|
||||
pnpm test:workshop-image
|
||||
pnpm test:workshop
|
||||
```
|
||||
|
||||
The smoke test starts a temporary container and verifies the documentation homepage, `/workshop/`, the migrated public collection, and `/api/health`.
|
||||
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.
|
||||
|
||||
## GitHub Actions and ACT
|
||||
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 and comment ownership, one vote/report per user and submission, and
|
||||
administrator-only moderation. Each new submission receives one real author-owned upvote. Reports are private to
|
||||
administrators 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.
|
||||
|
||||
`.github/workflows/deployment-workshop.yml` runs only for changes below `apps/docs/` or `apps/workshop/`, plus manual dispatches. It builds and tests the combined image, then publishes:
|
||||
## Production docs preview
|
||||
|
||||
- `ghcr.io/homarr-labs/workshop:latest`;
|
||||
- `ghcr.io/homarr-labs/workshop:sha-<commit>`.
|
||||
|
||||
Run the same build and smoke test locally without registry credentials:
|
||||
Build the documentation and serve it from PocketBase using the same production image layout as the hosted Workshop:
|
||||
|
||||
```sh
|
||||
pnpm ci:act:workshop
|
||||
pnpm docker:docs
|
||||
```
|
||||
|
||||
See `.github/act/workshop.md` for the direct ACT command and Docker socket behavior.
|
||||
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.
|
||||
|
||||
## VPS deployment
|
||||
|
||||
Copy `apps/workshop/docker-compose.yml` to the checkout on the VPS, authenticate Docker to GHCR if the package is private, and run from the repository root. Compose reads the optional root `.env` automatically:
|
||||
Point a local Homarr checkout at it with:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml pull
|
||||
docker compose -f apps/workshop/docker-compose.yml up -d --no-build
|
||||
WORKSHOP_API_URL=http://127.0.0.1:3003 pnpm dev
|
||||
```
|
||||
|
||||
The service binds to `127.0.0.1:8090` by default. Put a TLS reverse proxy in front of it for `workshop.homarr.dev`, forward the original scheme and host, and persist the `homarr_workshop_deployment_data` volume. Back up that volume before PocketBase upgrades.
|
||||
The production Homarr image reads the same variable when the container starts:
|
||||
|
||||
## Content schema
|
||||
```sh
|
||||
docker run -p 7575:7575 -e WORKSHOP_API_URL=http://127.0.0.1:3003 <homarr-image>
|
||||
```
|
||||
|
||||
- Custom widgets use `homarr-custom-widget-v2` JSON.
|
||||
- Custom CSS uses raw CSS tagged as `homarr-custom-css-v1`.
|
||||
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:
|
||||
|
||||
Client-side validation lives in `apps/docs/src/lib/workshop-schema.ts`. PocketBase collection rules and hooks remain in this directory.
|
||||
```sh
|
||||
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.
|
||||
@@ -2,27 +2,45 @@ name: homarr-workshop
|
||||
|
||||
services:
|
||||
workshop:
|
||||
image: ghcr.io/homarr-labs/workshop:${WORKSHOP_TAG:-latest}
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: development
|
||||
restart: unless-stopped
|
||||
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:${WORKSHOP_PORT:-8090}:8090"
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
|
||||
volumes:
|
||||
- workshop_data:/pb_data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:8090/api/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
- pb_data:/pb_data
|
||||
- ./pb_hooks:/pb_hooks:ro
|
||||
- ./pb_migrations:/pb_migrations:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
docs:
|
||||
profiles:
|
||||
- docs
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: production
|
||||
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:
|
||||
- pb_data:/pb_data
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
workshop_data:
|
||||
name: homarr_workshop_deployment_data
|
||||
pb_data:
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
chown -R pocketbase:pocketbase /pb_data
|
||||
exec su -p -s /bin/sh pocketbase -c 'exec /usr/local/bin/workshop-entrypoint'
|
||||
fi
|
||||
|
||||
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,97 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>New Comment on Homarr Workshop</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f4f4f4; font-family: Helvetica, Arial, sans-serif">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4">
|
||||
<tr>
|
||||
<td align="center" style="padding: 50px 20px">
|
||||
<table
|
||||
role="presentation"
|
||||
width="600"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="width: 600px; max-width: 100%; background-color: #ffffff; border: 1px solid #000000"
|
||||
>
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="background-color: #000000; padding: 36px 20px">
|
||||
<a href="https://homarr.dev" target="_blank" style="text-decoration: none">
|
||||
<img
|
||||
src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.png"
|
||||
alt="Homarr"
|
||||
width="170"
|
||||
style="display: block; width: 170px; height: auto; border: 0; margin: 0 auto"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding: 45px 40px; color: #000000; font-size: 15px; line-height: 1.7">
|
||||
<p style="margin: 0 0 18px 0">Hi there,</p>
|
||||
<p style="margin: 0 0 18px 0">
|
||||
<strong>{{commenterName}}</strong> left a comment on your submission
|
||||
<strong>{{submissionTitle}}</strong>:
|
||||
</p>
|
||||
<blockquote
|
||||
style="
|
||||
margin: 0 0 24px 0;
|
||||
padding: 16px 20px;
|
||||
border-left: 3px solid #000000;
|
||||
background-color: #f9f9f9;
|
||||
color: #555555;
|
||||
"
|
||||
>
|
||||
{{commentExcerpt}}
|
||||
</blockquote>
|
||||
|
||||
<!-- CTA button -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="background-color: #000000">
|
||||
<a
|
||||
href="{{submissionUrl}}"
|
||||
target="_blank"
|
||||
style="
|
||||
display: inline-block;
|
||||
padding: 12px 28px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.5px;
|
||||
"
|
||||
>
|
||||
VIEW THE COMMENT
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Divider -->
|
||||
<tr>
|
||||
<td style="border-top: 1px solid #000000"></td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px 20px; color: #000000; font-size: 12px">
|
||||
<a href="https://homarr.dev" target="_blank" style="color: #000000; text-decoration: underline"
|
||||
>homarr.dev</a
|
||||
>
|
||||
· Homarr — 2026
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workshop report</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f4f4; font-family: Helvetica, Arial, sans-serif">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f4f4">
|
||||
<tr>
|
||||
<td align="center" style="padding: 50px 20px">
|
||||
<table
|
||||
role="presentation"
|
||||
width="600"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="width: 600px; max-width: 100%; background: #ffffff; border: 1px solid #dc2626"
|
||||
>
|
||||
<tr>
|
||||
<td style="background: #991b1b; padding: 28px 36px; color: #ffffff">
|
||||
<div style="font-size: 12px; font-weight: bold; letter-spacing: 1px; text-transform: uppercase">
|
||||
Moderation alert
|
||||
</div>
|
||||
<div style="margin-top: 8px; font-size: 22px; font-weight: bold">A Workshop submission was reported</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 36px; color: #18181b; font-size: 15px; line-height: 1.65">
|
||||
<p style="margin: 0 0 18px">
|
||||
<strong>{{reporterName}}</strong> reported <a href="{{submissionUrl}}">{{submissionTitle}}</a>.
|
||||
</p>
|
||||
<p style="margin: 0 0 8px; font-size: 12px; font-weight: bold; text-transform: uppercase; color: #991b1b">
|
||||
{{category}}
|
||||
</p>
|
||||
<blockquote
|
||||
style="margin: 0 0 26px; padding: 16px 18px; border-left: 3px solid #dc2626; background: #fef2f2"
|
||||
>
|
||||
{{explanation}}
|
||||
</blockquote>
|
||||
<a
|
||||
href="{{adminUrl}}"
|
||||
style="display: inline-block; padding: 12px 20px; background: #991b1b; color: #ffffff; font-weight: bold; text-decoration: none"
|
||||
>
|
||||
REVIEW REPORT
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workshop submission removed</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f4f4; font-family: Helvetica, Arial, sans-serif">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f4f4f4">
|
||||
<tr>
|
||||
<td align="center" style="padding: 50px 20px">
|
||||
<table
|
||||
role="presentation"
|
||||
width="600"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="width: 600px; max-width: 100%; background: #ffffff; border: 1px solid #27272a"
|
||||
>
|
||||
<tr>
|
||||
<td style="background: #18181b; padding: 28px 36px; color: #ffffff">
|
||||
<div style="font-size: 12px; font-weight: bold; letter-spacing: 1px; text-transform: uppercase">
|
||||
Homarr Workshop
|
||||
</div>
|
||||
<div style="margin-top: 8px; font-size: 22px; font-weight: bold">Your submission was removed</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 36px; color: #18181b; font-size: 15px; line-height: 1.65">
|
||||
<p style="margin: 0 0 18px">A Workshop administrator removed your submission:</p>
|
||||
<div style="margin: 0 0 24px; padding: 16px 18px; border-left: 3px solid #dc2626; background: #fef2f2">
|
||||
<strong>{{submissionTitle}}</strong>
|
||||
</div>
|
||||
<p style="margin: 0">
|
||||
The submission is no longer available in Workshop. Widgets or CSS already installed by users are not
|
||||
removed from their Homarr instances.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,75 +1,154 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// --- Submissions ---
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
e.record.set("author", e.auth?.id);
|
||||
e.record.set("upvotes", 0);
|
||||
e.record.set("downvotes", 0);
|
||||
e.record.set("version", 1);
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
onRecordAfterCreateSuccess((event) => {
|
||||
try {
|
||||
var votes = e.app.findCollectionByNameOrId("votes");
|
||||
var vote = new Record(votes);
|
||||
vote.set("submission", e.record.id);
|
||||
vote.set("user", e.record.get("author"));
|
||||
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);
|
||||
e.app.save(vote);
|
||||
} catch (err) {
|
||||
console.log("auto-upvote failed for submission " + e.record.id + ": " + err);
|
||||
event.app.save(vote);
|
||||
} catch (error) {
|
||||
console.log(`Workshop initial upvote failed for ${event.record.id}: ${error}`);
|
||||
}
|
||||
e.next();
|
||||
event.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
var original = e.app.findRecordById("submissions", e.record.id);
|
||||
e.record.set("author", original.get("author"));
|
||||
e.record.set("upvotes", original.get("upvotes"));
|
||||
e.record.set("downvotes", original.get("downvotes"));
|
||||
e.record.set("type", original.get("type"));
|
||||
e.record.set("schemaVersion", original.get("schemaVersion"));
|
||||
e.record.set("version", original.get("version"));
|
||||
e.next();
|
||||
}, "submissions");
|
||||
const escapeHtml = (value) =>
|
||||
String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
// --- Votes ---
|
||||
const emailTemplate = (filename) => {
|
||||
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
|
||||
return $os.readFile($filepath.join(hooksPath, filename));
|
||||
};
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
var v = +e.record.get("value");
|
||||
if (v !== 1 && v !== -1) throw new BadRequestError("value must be 1 or -1");
|
||||
e.record.set("user", e.auth?.id);
|
||||
e.next();
|
||||
}, "votes");
|
||||
const sendEmail = (app, recipientEmail, subject, text, html) => {
|
||||
const sender = app.settings().meta;
|
||||
app.newMailClient().send(
|
||||
new MailerMessage({
|
||||
from: { address: sender.senderAddress, name: sender.senderName },
|
||||
to: [{ address: recipientEmail }],
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
var v = +e.record.get("value");
|
||||
if (v !== 1 && v !== -1) throw new BadRequestError("value must be 1 or -1");
|
||||
var original = e.app.findRecordById("votes", e.record.id);
|
||||
e.record.set("user", original.get("user"));
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.next();
|
||||
}, "votes");
|
||||
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(/\/$/, "");
|
||||
|
||||
// Vote count hooks removed — the `marketplace` view computes upvotes/downvotes via SQL
|
||||
if (!recipientEmail || recipient.id === commenter.id || !publicOrigin) {
|
||||
event.next();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Reports & Comments ---
|
||||
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 template = emailTemplate("comment-email.html")
|
||||
.replaceAll("{{commenterName}}", escapeHtml(commenterName))
|
||||
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
|
||||
.replaceAll("{{commentExcerpt}}", escapeHtml(rawExcerpt))
|
||||
.replaceAll("{{submissionUrl}}", escapeHtml(submissionUrl));
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
e.record.set("user", e.auth?.id);
|
||||
e.next();
|
||||
sendEmail(
|
||||
event.app,
|
||||
recipientEmail,
|
||||
`${commenterName} commented on ${submissionTitle}`,
|
||||
`${commenterName} commented on “${submissionTitle}”:\n\n${rawExcerpt}\n\n${submissionUrl}`,
|
||||
template,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(`Workshop comment email failed for ${event.record.id}: ${error}`);
|
||||
}
|
||||
event.next();
|
||||
}, "comments");
|
||||
|
||||
onRecordAfterCreateSuccess((event) => {
|
||||
try {
|
||||
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
|
||||
const reporter = event.app.findRecordById("users", event.record.get("reporter"));
|
||||
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
|
||||
const admins = event.app.findAllRecords("users").filter((user) => user.getBool("isAdmin") && Boolean(user.email()));
|
||||
|
||||
if (!publicOrigin || admins.length === 0) {
|
||||
event.next();
|
||||
return;
|
||||
}
|
||||
|
||||
const reporterName = reporter.getString("displayName") || "A community member";
|
||||
const submissionTitle = submission.getString("title");
|
||||
const category = event.record.getString("category");
|
||||
const explanation = event.record.getString("explanation");
|
||||
const adminUrl = `${publicOrigin}/workshop/admin`;
|
||||
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
|
||||
const template = emailTemplate("report-email.html")
|
||||
.replaceAll("{{reporterName}}", escapeHtml(reporterName))
|
||||
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
|
||||
.replaceAll("{{category}}", escapeHtml(category))
|
||||
.replaceAll("{{explanation}}", escapeHtml(explanation))
|
||||
.replaceAll("{{adminUrl}}", escapeHtml(adminUrl))
|
||||
.replaceAll("{{submissionUrl}}", escapeHtml(submissionUrl));
|
||||
|
||||
for (const admin of admins) {
|
||||
try {
|
||||
sendEmail(
|
||||
event.app,
|
||||
admin.email(),
|
||||
`Workshop report: ${submissionTitle}`,
|
||||
`${reporterName} reported “${submissionTitle}” for ${category}.\n\n${explanation}\n\nReview: ${adminUrl}`,
|
||||
template,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(`Workshop report email failed for admin ${admin.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Workshop report email failed for ${event.record.id}: ${error}`);
|
||||
}
|
||||
event.next();
|
||||
}, "reports");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
e.record.set("author", e.auth?.id);
|
||||
e.next();
|
||||
}, "comments");
|
||||
onRecordDeleteRequest((event) => {
|
||||
const submissionId = event.record.id;
|
||||
const submissionTitle = event.record.getString("title");
|
||||
const authorId = event.record.get("author");
|
||||
const actor = event.requestInfo().auth;
|
||||
const notifyAuthor = actor && actor.getBool("isAdmin") && actor.id !== authorId;
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
var original = e.app.findRecordById("comments", e.record.id);
|
||||
e.record.set("author", original.get("author"));
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.next();
|
||||
}, "comments");
|
||||
event.next();
|
||||
|
||||
if (!notifyAuthor) return;
|
||||
|
||||
try {
|
||||
const author = event.app.findRecordById("users", authorId);
|
||||
const recipientEmail = author.email();
|
||||
if (!recipientEmail) return;
|
||||
|
||||
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
|
||||
const workshopUrl = publicOrigin ? `${publicOrigin}/workshop/` : "";
|
||||
const template = emailTemplate("submission-deleted-email.html").replaceAll(
|
||||
"{{submissionTitle}}",
|
||||
escapeHtml(submissionTitle),
|
||||
);
|
||||
const text = `A Workshop administrator removed your submission “${submissionTitle}”.${
|
||||
workshopUrl ? `\n\nReturn to Workshop: ${workshopUrl}` : ""
|
||||
}`;
|
||||
|
||||
sendEmail(event.app, recipientEmail, `Your Workshop submission was removed`, text, template);
|
||||
} catch (error) {
|
||||
console.log(`Workshop deletion email failed for ${submissionId}: ${error}`);
|
||||
}
|
||||
}, "submissions");
|
||||
@@ -0,0 +1,218 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
let users;
|
||||
try {
|
||||
users = app.findCollectionByNameOrId("users");
|
||||
} 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 = "";
|
||||
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;
|
||||
app.save(users);
|
||||
|
||||
const submissions = new Collection({
|
||||
type: "base",
|
||||
name: "submissions",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
|
||||
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",
|
||||
maxSelect: 5,
|
||||
maxSize: 5242880,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
|
||||
thumbs: ["480x320", "960x640"],
|
||||
},
|
||||
{ type: "relation", name: "author", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
submissions.addIndex("idx_submissions_author", false, "author", "");
|
||||
submissions.addIndex("idx_submissions_created", false, "created", "");
|
||||
app.save(submissions);
|
||||
|
||||
const votes = new Collection({
|
||||
type: "base",
|
||||
name: "votes",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "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: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "submission",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "number", name: "value", required: true, onlyInt: true, min: -1, max: 1 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
votes.addIndex("idx_votes_user_submission", true, "user, submission", "");
|
||||
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: adminRule,
|
||||
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
|
||||
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
|
||||
deleteRule: adminRule,
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "submission",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{
|
||||
type: "relation",
|
||||
name: "reporter",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "category",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
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 },
|
||||
],
|
||||
});
|
||||
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
|
||||
reports.addIndex("idx_reports_created", false, "created", "");
|
||||
app.save(reports);
|
||||
|
||||
const listings = new Collection({
|
||||
type: "view",
|
||||
name: "workshop_listings",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
viewQuery: `
|
||||
SELECT s.id, s.type, s.title, s.description, s.widgetSchema, s.screenshots, s.author,
|
||||
u.displayName AS authorName, u.avatar AS authorAvatar, 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
|
||||
`,
|
||||
});
|
||||
app.save(listings);
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...settings.rateLimits.rules.filter(
|
||||
(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", "comments", "votes", "submissions"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
|
||||
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
|
||||
|
||||
cleanup() {
|
||||
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
|
||||
|
||||
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
|
||||
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 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
|
||||
@@ -0,0 +1,305 @@
|
||||
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
|
||||
|
||||
const request = async (path, init = {}) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
|
||||
return body;
|
||||
};
|
||||
|
||||
const expectStatus = async (path, init, expected) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
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", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: "workshop-test@example.invalid", password: "WorkshopLocalTest123!" }),
|
||||
});
|
||||
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", "comments", "reports", "workshop_listings"]) {
|
||||
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
|
||||
}
|
||||
for (const removed of ["workshop_admin_actions", "workshop_admins"]) {
|
||||
if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`);
|
||||
}
|
||||
|
||||
// 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,
|
||||
body: JSON.stringify({ ...usersCollection, passwordAuth: { enabled: true, identityFields: ["email"] } }),
|
||||
});
|
||||
|
||||
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);
|
||||
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",
|
||||
name: "Workshop runtime probe",
|
||||
sources: {
|
||||
default: {
|
||||
name: "API",
|
||||
baseUrl: "https://example.com",
|
||||
networkScope: "public",
|
||||
auth: "none",
|
||||
},
|
||||
},
|
||||
requests: {},
|
||||
options: {},
|
||||
template: "<Text>Runtime probe</Text>",
|
||||
};
|
||||
|
||||
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 (
|
||||
submission.author !== author.id ||
|
||||
submission.title !== "Workshop runtime probe" ||
|
||||
submission.widgetSchema !== widget.$schema
|
||||
) {
|
||||
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",
|
||||
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");
|
||||
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}`,
|
||||
{ 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: visitorSession.headers,
|
||||
body: JSON.stringify({ submission: submission.id, user: visitor.id, value: 1 }),
|
||||
});
|
||||
await expectStatus(
|
||||
"/api/collections/votes/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({ submission: submission.id, user: visitor.id, value: 1 }),
|
||||
},
|
||||
400,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/votes/records/${vote.id}`,
|
||||
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ user: author.id }) },
|
||||
404,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/votes/records/${vote.id}`,
|
||||
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ value: 0 }) },
|
||||
404,
|
||||
);
|
||||
|
||||
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
|
||||
if (
|
||||
listing.score !== 2 ||
|
||||
listing.upvotes !== 2 ||
|
||||
listing.downvotes !== 0 ||
|
||||
listing.authorName !== "Widget Author" ||
|
||||
listing.widgetSchema !== widget.$schema
|
||||
) {
|
||||
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",
|
||||
{
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({
|
||||
submission: submission.id,
|
||||
reporter: author.id,
|
||||
category: "other",
|
||||
explanation: "Spoofed reporter",
|
||||
status: "open",
|
||||
}),
|
||||
},
|
||||
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",
|
||||
status: "open",
|
||||
}),
|
||||
});
|
||||
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
|
||||
await expectStatus("/api/collections/reports/records", { headers: visitorSession.headers }, 200);
|
||||
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
|
||||
if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop administrators");
|
||||
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");
|
||||
|
||||
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");
|
||||
}
|
||||
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(
|
||||
`/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