feat(custom-widgets): complete JSX v2

This commit is contained in:
Thomas Camlong committed 2026-07-18 15:33:25 +02:00
1 parent c0f1d6624c
commit eae48f50fd
263 files changed
+65503 -17816

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
import PocketBase, { ClientResponseError } from "pocketbase";
import type {
WorkshopAdminAction,
WorkshopReport,
WorkshopSubmissionDetail,
WorkshopSubmissionInput,
WorkshopSubmissionSummary,
WorkshopUser,
WorkshopVote,
} from "./schema";
import {
WORKSHOP_API_URL,
WORKSHOP_REQUEST_TIMEOUT_MS,
WorkshopError,
workshopAdminActionSchema,
workshopReportSchema,
workshopSubmissionDetailSchema,
workshopSubmissionInputSchema,
workshopSubmissionSummarySchema,
workshopScreenshotsSchema,
workshopUserSchema,
workshopVoteSchema,
} from "./schema";
export interface WorkshopListOptions {
page?: number;
perPage?: number;
sort?: "top" | "newest";
search?: string;
author?: string;
signal?: AbortSignal;
}
export interface WorkshopPage<T> {
page: number;
perPage: number;
totalPages: number;
totalItems: number;
items: T[];
}
const requestSignal = (signal?: AbortSignal) =>
AbortSignal.any(
signal
? [signal, AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)]
: [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)],
);
function workshopError(error: unknown, fallback: string) {
if (error instanceof WorkshopError) return error;
if (error instanceof ClientResponseError) {
const code =
error.status === 401
? "authentication_required"
: error.status === 403
? "forbidden"
: error.status === 404
? "not_found"
: error.status === 409
? "conflict"
: error.status === 429
? "rate_limited"
: error.status >= 500
? "unavailable"
: "unknown";
return new WorkshopError(code, error.data?.message || error.message || fallback, error.status);
}
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError"))
return new WorkshopError("unavailable", `${fallback}. The request timed out.`);
return new WorkshopError("unknown", error instanceof Error ? error.message : fallback);
}
export class WorkshopClient {
public readonly pocketBase: PocketBase;
public constructor(public readonly baseUrl = WORKSHOP_API_URL) {
this.pocketBase = new PocketBase(baseUrl.replace(/\/$/u, ""));
}
public get currentUser(): WorkshopUser | null {
const record = this.pocketBase.authStore.record;
if (!record) return null;
return workshopUserSchema.parse({
id: record.id,
displayName: record.displayName || record.name || record.username || "Community member",
avatarUrl: record.avatarUrl || record.avatar || "",
isAdmin: record.isAdmin === true,
});
}
public subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true);
}
public async refreshAuth() {
if (!this.pocketBase.authStore.isValid) return null;
try {
await this.pocketBase.collection("users").authRefresh();
const user = this.currentUser;
if (!user) return null;
const admin = await this.pocketBase
.collection("workshop_admins")
.getFirstListItem(this.pocketBase.filter("user = {:user}", { user: user.id }))
.catch(() => null);
return { ...user, isAdmin: admin !== null };
} catch {
this.pocketBase.authStore.clear();
return null;
}
}
public async signInWithGitHub() {
try {
const methods = await this.pocketBase.collection("users").listAuthMethods();
if (!methods.oauth2.providers.some((provider) => provider.name === "github"))
throw new WorkshopError("unavailable", "GitHub login is not configured");
await this.pocketBase
.collection("users")
.authWithOAuth2({ provider: "github", createData: { displayName: "Community member" } });
return this.refreshAuth();
} catch (error) {
throw workshopError(error, "GitHub sign-in failed");
}
}
public signOut() {
this.pocketBase.authStore.clear();
}
public async list(options: WorkshopListOptions = {}): Promise<WorkshopPage<WorkshopSubmissionSummary>> {
const filters: string[] = [];
if (options.search?.trim())
filters.push(this.pocketBase.filter("title ~ {:search}", { search: options.search.trim() }));
if (options.author) filters.push(this.pocketBase.filter("author = {:author}", { author: options.author }));
try {
const result = await this.pocketBase
.collection("workshop_listings")
.getList(options.page ?? 1, options.perPage ?? 24, {
filter: filters.join(" && "),
sort: options.sort === "newest" ? "-created" : "-score,-created",
signal: requestSignal(options.signal),
});
return { ...result, items: result.items.map((item) => workshopSubmissionSummarySchema.parse(item)) };
} catch (error) {
throw workshopError(error, "Failed to load Workshop submissions");
}
}
public async listAll(options: Omit<WorkshopListOptions, "page" | "perPage"> = {}) {
const filters: string[] = [];
if (options.search?.trim())
filters.push(this.pocketBase.filter("title ~ {:search}", { search: options.search.trim() }));
if (options.author) filters.push(this.pocketBase.filter("author = {:author}", { author: options.author }));
try {
const items = await this.pocketBase.collection("workshop_listings").getFullList({
batch: 200,
filter: filters.join(" && "),
sort: options.sort === "top" ? "-score,-created" : "-created",
signal: requestSignal(options.signal),
});
return items.map((item) => workshopSubmissionSummarySchema.parse(item));
} catch (error) {
throw workshopError(error, "Failed to load Workshop submissions");
}
}
public async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
try {
const [submission, listing] = await Promise.all([
this.pocketBase.collection("submissions").getOne(id, { signal: requestSignal(signal) }),
this.pocketBase.collection("workshop_listings").getOne(id, { signal: requestSignal(signal) }),
]);
return workshopSubmissionDetailSchema.parse({ ...submission, ...listing, content: submission.content });
} catch (error) {
throw workshopError(error, "Submission not found");
}
}
public fileUrl(submissionId: string, filename: string, thumb?: string) {
return `${this.baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}${thumb ? `?thumb=${encodeURIComponent(thumb)}` : ""}`;
}
public async create(input: WorkshopSubmissionInput, screenshots: File[] = []) {
const parsed = workshopSubmissionInputSchema.parse(input);
workshopScreenshotsSchema.parse(screenshots);
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
data.set("author", this.currentUser?.id ?? "");
data.set("authorName", this.currentUser?.displayName ?? "Community member");
screenshots.forEach((file) => data.append("screenshots", file));
try {
const result = await this.pocketBase.collection("submissions").create(data);
return this.get(result.id);
} catch (error) {
throw workshopError(error, "Failed to publish submission");
}
}
public async update(id: string, input: WorkshopSubmissionInput, screenshots: File[] = [], removed: string[] = []) {
const parsed = workshopSubmissionInputSchema.parse(input);
workshopScreenshotsSchema.parse(screenshots);
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
screenshots.forEach((file) => data.append("screenshots+", file));
removed.forEach((file) => data.append("screenshots-", file));
try {
await this.pocketBase.collection("submissions").update(id, data);
return this.get(id);
} catch (error) {
throw workshopError(error, "Failed to update submission");
}
}
public async delete(id: string, reason = "Deleted by author") {
try {
await this.pocketBase.collection("submissions").delete(id, { headers: { "x-workshop-reason": reason } });
} catch (error) {
throw workshopError(error, "Failed to delete submission");
}
}
public async vote(submission: string, value: 1 | -1): Promise<WorkshopVote | null> {
const user = this.currentUser?.id ?? "";
const filter = this.pocketBase.filter("submission = {:submission} && user = {:user}", { submission, user });
try {
const existing = await this.pocketBase
.collection("votes")
.getFirstListItem(filter)
.catch((error: unknown) =>
error instanceof ClientResponseError && error.status === 404 ? null : Promise.reject(error),
);
if (existing?.value === value) {
await this.pocketBase.collection("votes").delete(existing.id);
return null;
}
const record = existing
? await this.pocketBase.collection("votes").update(existing.id, { value })
: await this.pocketBase.collection("votes").create({ submission, user, value });
return workshopVoteSchema.parse(record);
} catch (error) {
throw workshopError(error, "Failed to save vote");
}
}
public async report(submission: string, category: WorkshopReport["category"], explanation: string) {
try {
return workshopReportSchema.parse(
await this.pocketBase
.collection("reports")
.create({ submission, reporter: this.currentUser?.id ?? "", category, explanation, status: "open" }),
);
} catch (error) {
throw workshopError(error, "Failed to report submission");
}
}
public async listReports(): Promise<WorkshopReport[]> {
try {
const items = await this.pocketBase
.collection("reports")
.getFullList({ batch: 200, filter: "status = 'open'", sort: "-created", expand: "reporter,submission" });
return items.map((item) =>
workshopReportSchema.parse({
...item,
reporterName: item.expand?.reporter?.displayName,
submissionTitle: item.expand?.submission?.title,
}),
);
} catch (error) {
throw workshopError(error, "Failed to load reports");
}
}
public async dismissReport(id: string, reason: string) {
try {
return workshopReportSchema.parse(
await this.pocketBase.collection("reports").update(id, { status: "dismissed", dismissalReason: reason }),
);
} catch (error) {
throw workshopError(error, "Failed to dismiss report");
}
}
public async listAdminActions(): Promise<WorkshopAdminAction[]> {
try {
const items = await this.pocketBase
.collection("workshop_admin_actions")
.getFullList({ batch: 200, sort: "-created", expand: "actor" });
return items.map((item) =>
workshopAdminActionSchema.parse({ ...item, actorName: item.expand?.actor?.displayName }),
);
} catch (error) {
throw workshopError(error, "Failed to load administrator history");
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./client";
export * from "./schema";
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import { validateWorkshopWidget } from "./schema";
describe("Workshop widget validation", () => {
test("accepts a canonical credential-free widget", () => {
expect(validateWorkshopWidget(JSON.stringify(CUSTOM_WIDGET_STARTER)).success).toBe(true);
});
test("rejects literal credentials in templates and icon URLs", () => {
expect(
validateWorkshopWidget(
JSON.stringify({
...CUSTOM_WIDGET_STARTER,
template: "<Text>Authorization: Bearer literal-secret-value</Text>",
}),
).success,
).toBe(false);
expect(
validateWorkshopWidget(
JSON.stringify({
...CUSTOM_WIDGET_STARTER,
iconUrl: "https://example.com/icon.png?clientSecret=literal-secret",
}),
).success,
).toBe(false);
expect(
validateWorkshopWidget(
JSON.stringify({
...CUSTOM_WIDGET_STARTER,
requests: [
{
id: "load",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/load",
parameters: {},
staticHeaders: { "X-Custom": "Bearer literal-secret-value" },
auth: "none",
minimumBoardPermission: "view",
trigger: "load",
},
],
}),
).success,
).toBe(false);
});
});
+197
View File
@@ -0,0 +1,197 @@
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { z } from "zod/v4";
export const WORKSHOP_API_URL = "https://homarr.dev";
export const WORKSHOP_WEB_URL = "https://homarr.dev/workshop";
export const MAX_WORKSHOP_CONTENT_LENGTH = 1_000_000;
export const MAX_WORKSHOP_SCREENSHOTS = 5;
export const MAX_WORKSHOP_SCREENSHOT_BYTES = 5 * 1024 * 1024;
export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
export const workshopReportStatusSchema = z.enum(["open", "dismissed"]);
const workshopFileListSchema = z
.preprocess((value) => {
if (typeof value !== "string") return value;
try {
return JSON.parse(value) as unknown;
} catch {
return [];
}
}, z.array(z.string()))
.default([]);
export const workshopUserSchema = z.object({
id: z.string(),
displayName: z.string(),
avatarUrl: z.string().default(""),
isAdmin: z.boolean().default(false),
});
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
const workshopSubmissionBaseSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().default(""),
contentHash: z.string(),
screenshots: workshopFileListSchema,
revision: z.number().int().positive(),
changelog: z.string().default(""),
author: z.string(),
authorName: z.string(),
score: z.number().int(),
upvotes: z.number().int(),
downvotes: z.number().int(),
created: z.string(),
updated: z.string(),
});
export const workshopSubmissionSummarySchema = workshopSubmissionBaseSchema;
export type WorkshopSubmissionSummary = z.infer<typeof workshopSubmissionSummarySchema>;
export const workshopSubmissionDetailSchema = workshopSubmissionBaseSchema.extend({ content: z.string() });
export type WorkshopSubmissionDetail = z.infer<typeof workshopSubmissionDetailSchema>;
export const workshopSubmissionInputSchema = z
.object({
title: z.string().trim().min(3).max(100),
description: z.string().trim().max(2_000).default(""),
content: z.string().min(1).max(MAX_WORKSHOP_CONTENT_LENGTH),
changelog: z.string().trim().max(2_000).default(""),
})
.superRefine((input, context) => {
const result = validateWorkshopWidget(input.content);
if (!result.success) context.addIssue({ code: "custom", path: ["content"], message: result.error });
});
export type WorkshopSubmissionInput = z.infer<typeof workshopSubmissionInputSchema>;
export const workshopScreenshotsSchema = z
.array(
z.object({
size: z.number().max(MAX_WORKSHOP_SCREENSHOT_BYTES),
type: z.enum(WORKSHOP_SCREENSHOT_MIME_TYPES),
}),
)
.max(MAX_WORKSHOP_SCREENSHOTS);
export const workshopVoteSchema = z.object({
id: z.string(),
submission: z.string(),
user: z.string(),
value: z.union([z.literal(1), z.literal(-1)]),
});
export type WorkshopVote = z.infer<typeof workshopVoteSchema>;
export const workshopReportSchema = z.object({
id: z.string(),
submission: z.string(),
reporter: z.string(),
reporterName: z.string().default("Community member"),
submissionTitle: z.string().default("Deleted submission"),
category: workshopReportCategorySchema,
explanation: z.string(),
status: workshopReportStatusSchema,
created: z.string(),
updated: z.string(),
});
export type WorkshopReport = z.infer<typeof workshopReportSchema>;
export const workshopAdminActionSchema = z.object({
id: z.string(),
actor: z.string(),
actorName: z.string().default("Workshop administrator"),
action: z.enum(["delete_submission", "dismiss_report"]),
submissionId: z.string().default(""),
reportId: z.string().default(""),
reason: z.string().default(""),
snapshot: z.string().default(""),
created: z.string(),
});
export type WorkshopAdminAction = z.infer<typeof workshopAdminActionSchema>;
export type WorkshopValidationResult =
| { success: true; data: z.infer<typeof customWidgetImportSchema> }
| { success: false; error: string };
export function validateWorkshopWidget(content: string): WorkshopValidationResult {
try {
const parsed: unknown = JSON.parse(content);
const result = customWidgetImportSchema.safeParse(parsed);
if (!result.success) return { success: false, error: result.error.issues[0]?.message ?? "Invalid widget" };
if (containsCredentialLikeValue(result.data)) {
return { success: false, error: "Widget exports must not contain credentials or credential-like static values" };
}
return { success: true, data: result.data };
} catch {
return { success: false, error: "Widget content is not valid JSON" };
}
}
function containsCredentialLikeValue(widget: z.infer<typeof customWidgetImportSchema>) {
const sensitiveName =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
return (
credentialTextPattern.test(widget.template) ||
widget.sources.some((source) => {
const url = new URL(source.baseUrl);
return Boolean(url.username || url.password || url.search || url.hash);
}) ||
(widget.iconUrl
? [...new URL(widget.iconUrl).searchParams].some(
([name, value]) => sensitiveName.test(name) && value.trim().length > 0,
)
: false) ||
widget.requests.some(
(request) =>
Object.entries(request.staticHeaders ?? {}).some(
([name, value]) =>
(sensitiveName.test(name) && value.trim().length > 0) || /^\s*bearer\s+\S{8,}\s*$/iu.test(value),
) ||
containsCredential(request.bodyTemplate, "", sensitiveName) ||
containsCredential(request.queryTemplate, "", sensitiveName),
) ||
containsCredential(widget.defaultOptions, "", sensitiveName) ||
containsCredential(widget.defaultState, "", sensitiveName)
);
}
const credentialTextPattern =
/(?:\bauthorization\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._~+/%-]{8,}|\b(?:api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)\s*[:=]\s*["'][^"']{4,}["'])/iu;
function containsCredential(value: unknown, key: string, pattern: RegExp): boolean {
if (pattern.test(key) && typeof value === "string" && value.trim().length > 0) return true;
if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, "", pattern));
if (value === null || typeof value !== "object") return false;
const entries = Object.entries(value);
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") return false;
return entries.some(([childKey, child]) => containsCredential(child, childKey, pattern));
}
export function workshopExportFilename(title: string) {
const safe =
title
.trim()
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/^-|-$/g, "") || "homarr-widget";
return `${safe}.json`;
}
export class WorkshopError extends Error {
public constructor(
public readonly code:
| "authentication_required"
| "forbidden"
| "not_found"
| "conflict"
| "rate_limited"
| "unavailable"
| "unknown",
message: string,
public readonly status?: number,
) {
super(message);
this.name = "WorkshopError";
}
}