feat(custom-widgets): complete JSX v2
This commit is contained in:
263 files changed
+65503
-17816
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./client";
|
||||
export * from "./schema";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user