feat: community workshop for custom widgets and CSS (#5984)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
121a83040d
commit
c69e33ac8c
58 files changed
+4475
-112
No files matched your search
@@ -0,0 +1,3 @@
|
||||
pb_data/
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,54 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
|
||||
FROM node:24.18.0-alpine AS docs
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
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 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
|
||||
EXPOSE 8090
|
||||
VOLUME ["/pb_data"]
|
||||
|
||||
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"]
|
||||
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/.docusaurus
|
||||
**/.next
|
||||
**/.turbo
|
||||
**/build
|
||||
**/coverage
|
||||
**/dist
|
||||
**/node_modules
|
||||
**/pb_data
|
||||
.pnpm-store
|
||||
e2e
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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.
|
||||
|
||||
## Local production-like run
|
||||
|
||||
Configure optional values in the repository-root `.env`, then run:
|
||||
|
||||
```sh
|
||||
pnpm dev:workshop
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- documentation: `http://localhost:8090/`;
|
||||
- Workshop: `http://localhost:8090/workshop/`;
|
||||
- PocketBase API: `http://localhost:8090/api/`;
|
||||
- PocketBase dashboard: `http://localhost:8090/_/`.
|
||||
|
||||
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.
|
||||
|
||||
## GitHub OAuth
|
||||
|
||||
Create a GitHub OAuth application with these local values:
|
||||
|
||||
- homepage: `http://localhost:8090`;
|
||||
- callback: `http://localhost:8090/api/oauth2-redirect`.
|
||||
|
||||
Set these values only in the repository-root `.env`:
|
||||
|
||||
```dotenv
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Local image validation
|
||||
|
||||
Build and test the exact runtime shape:
|
||||
|
||||
```sh
|
||||
docker build -f apps/workshop/Dockerfile -t homarr/workshop:ci .
|
||||
pnpm test:workshop-image
|
||||
```
|
||||
|
||||
The smoke test starts a temporary container and verifies the documentation homepage, `/workshop/`, the migrated public collection, and `/api/health`.
|
||||
|
||||
## GitHub Actions and ACT
|
||||
|
||||
`.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:
|
||||
|
||||
- `ghcr.io/homarr-labs/workshop:latest`;
|
||||
- `ghcr.io/homarr-labs/workshop:sha-<commit>`.
|
||||
|
||||
Run the same build and smoke test locally without registry credentials:
|
||||
|
||||
```sh
|
||||
pnpm ci:act:workshop
|
||||
```
|
||||
|
||||
See `.github/act/workshop.md` for the direct ACT command and Docker socket behavior.
|
||||
|
||||
## 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:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml pull
|
||||
docker compose -f apps/workshop/docker-compose.yml up -d --no-build
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Content schema
|
||||
|
||||
- Custom widgets use `homarr-custom-widget-v2` JSON.
|
||||
- Custom CSS uses raw CSS tagged as `homarr-custom-css-v1`.
|
||||
|
||||
Client-side validation lives in `apps/docs/src/lib/workshop-schema.ts`. PocketBase collection rules and hooks remain in this directory.
|
||||
@@ -0,0 +1,28 @@
|
||||
name: homarr-workshop
|
||||
|
||||
services:
|
||||
workshop:
|
||||
image: ghcr.io/homarr-labs/workshop:${WORKSHOP_TAG:-latest}
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
ports:
|
||||
- "127.0.0.1:${WORKSHOP_PORT:-8090}: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
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
workshop_data:
|
||||
name: homarr_workshop_deployment_data
|
||||
@@ -0,0 +1,75 @@
|
||||
/// <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) => {
|
||||
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"));
|
||||
vote.set("value", 1);
|
||||
e.app.save(vote);
|
||||
} catch (err) {
|
||||
console.log("auto-upvote failed for submission " + e.record.id + ": " + err);
|
||||
}
|
||||
e.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");
|
||||
|
||||
// --- Votes ---
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
// Vote count hooks removed — the `marketplace` view computes upvotes/downvotes via SQL
|
||||
|
||||
// --- Reports & Comments ---
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
e.record.set("user", e.auth?.id);
|
||||
e.next();
|
||||
}, "reports");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
e.record.set("author", e.auth?.id);
|
||||
e.next();
|
||||
}, "comments");
|
||||
|
||||
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");
|
||||
@@ -0,0 +1,172 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
app.save(users);
|
||||
|
||||
let submissions;
|
||||
try {
|
||||
submissions = app.findCollectionByNameOrId("submissions");
|
||||
} catch {
|
||||
submissions = new Collection({
|
||||
type: "base",
|
||||
name: "submissions",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != ''",
|
||||
updateRule: "author = @request.auth.id",
|
||||
deleteRule: "author = @request.auth.id",
|
||||
fields: [
|
||||
{ name: "type", type: "select", required: true, maxSelect: 1, values: ["css", "widget"] },
|
||||
{ name: "title", type: "text", required: true, min: 3, max: 100 },
|
||||
{ name: "description", type: "text", max: 2000 },
|
||||
{ name: "schemaVersion", type: "text", required: true, max: 100 },
|
||||
{ name: "content", type: "text", required: true, max: 1000000 },
|
||||
{
|
||||
name: "screenshots",
|
||||
type: "file",
|
||||
maxSelect: 5,
|
||||
maxSize: 5242880,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"],
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "relation",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ name: "upvotes", type: "number", onlyInt: true },
|
||||
{ name: "downvotes", type: "number", onlyInt: true },
|
||||
{ name: "version", type: "number", onlyInt: true },
|
||||
{ name: "changelog", type: "text", max: 2000 },
|
||||
{ name: "created", type: "autodate", onCreate: true },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
app.save(submissions);
|
||||
}
|
||||
|
||||
let votes;
|
||||
try {
|
||||
votes = app.findCollectionByNameOrId("votes");
|
||||
} catch {
|
||||
votes = new Collection({
|
||||
type: "base",
|
||||
name: "votes",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "@request.auth.id != ''",
|
||||
updateRule: "user = @request.auth.id",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
fields: [
|
||||
{
|
||||
name: "submission",
|
||||
type: "relation",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ name: "value", type: "number", required: true, onlyInt: true, min: -1, max: 1 },
|
||||
{ name: "created", type: "autodate", onCreate: true },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
votes.addIndex("idx_votes_unique_user_submission", true, "submission, user", "");
|
||||
app.save(votes);
|
||||
}
|
||||
|
||||
let reports;
|
||||
try {
|
||||
reports = app.findCollectionByNameOrId("reports");
|
||||
} catch {
|
||||
reports = new Collection({
|
||||
type: "base",
|
||||
name: "reports",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: "@request.auth.id != ''",
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "submission",
|
||||
type: "relation",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ name: "reason", type: "text", required: true, min: 3, max: 1000 },
|
||||
{ name: "created", type: "autodate", onCreate: true },
|
||||
],
|
||||
});
|
||||
app.save(reports);
|
||||
}
|
||||
|
||||
let comments;
|
||||
try {
|
||||
comments = app.findCollectionByNameOrId("comments");
|
||||
} catch {
|
||||
comments = new Collection({
|
||||
type: "base",
|
||||
name: "comments",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != ''",
|
||||
updateRule: "author = @request.auth.id",
|
||||
deleteRule: "author = @request.auth.id",
|
||||
fields: [
|
||||
{
|
||||
name: "submission",
|
||||
type: "relation",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "relation",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ name: "content", type: "text", required: true, min: 1, max: 2000 },
|
||||
{ name: "created", type: "autodate", onCreate: true },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
comments.addIndex("idx_comments_submission", false, "submission", "");
|
||||
app.save(comments);
|
||||
}
|
||||
},
|
||||
(app) => {
|
||||
for (const name of ["comments", "reports", "votes", "submissions"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
// Keep databases created by the original prototype valid after the
|
||||
// migration file was renamed with the feature.
|
||||
try {
|
||||
app.findCollectionByNameOrId("marketplace");
|
||||
return;
|
||||
} catch {
|
||||
// Fresh database; create the public view below.
|
||||
}
|
||||
|
||||
const collection = new Collection({
|
||||
name: "marketplace",
|
||||
type: "view",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
viewQuery: `
|
||||
SELECT
|
||||
s.id,
|
||||
s.type,
|
||||
s.title,
|
||||
s.description,
|
||||
s.schemaVersion,
|
||||
s.content,
|
||||
s.screenshots,
|
||||
s.version,
|
||||
s.changelog,
|
||||
s.author,
|
||||
s.created,
|
||||
s.updated,
|
||||
COALESCE(NULLIF(u.name, ''), 'unknown') as authorName,
|
||||
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,
|
||||
(SELECT COUNT(*) FROM comments c WHERE c.submission = s.id) as commentCount
|
||||
FROM submissions s
|
||||
LEFT JOIN users u ON u.id = s.author
|
||||
LEFT JOIN votes v ON v.submission = s.id
|
||||
GROUP BY s.id
|
||||
`,
|
||||
});
|
||||
app.save(collection);
|
||||
},
|
||||
(app) => {
|
||||
const collection = app.findCollectionByNameOrId("marketplace");
|
||||
app.delete(collection);
|
||||
},
|
||||
);
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
image="${1:-homarr/workshop:ci}"
|
||||
container="homarr-workshop-smoke-${$}"
|
||||
port="${WORKSHOP_SMOKE_PORT:-18090}"
|
||||
|
||||
cleanup() {
|
||||
docker rm --force "$container" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
docker run --detach --name "$container" --publish "127.0.0.1:${port}:8090" "$image" >/dev/null
|
||||
|
||||
attempt=0
|
||||
until curl --fail --silent "http://127.0.0.1:${port}/api/health" >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 30 ]; then
|
||||
docker logs "$container"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
curl --fail --silent "http://127.0.0.1:${port}/" | grep --quiet "Homarr"
|
||||
curl --fail --silent "http://127.0.0.1:${port}/workshop/" | grep --quiet "Workshop"
|
||||
curl --fail --silent "http://127.0.0.1:${port}/api/collections/marketplace/records?page=1&perPage=1" >/dev/null
|
||||
curl --fail --silent "http://127.0.0.1:${port}/api/health" | grep --quiet '"code":200'
|
||||
|
||||
echo "Workshop image serves Docusaurus and PocketBase successfully"
|
||||
Reference in new issue
Block a user