fix(ci): repair workshop and openapi validation
This commit is contained in:
1 parent
48ce83806d
commit
213108dd2f
5 files changed
+104
-88
No files matched your search
@@ -0,0 +1,93 @@
|
||||
const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
const CUSTOM_CSS_SCHEMA = "homarr-custom-css-v1";
|
||||
const MAX_CSS_LENGTH = 16_384;
|
||||
const MAX_CONTENT_LENGTH = 1_000_000;
|
||||
|
||||
const rejectRequest = (message) => {
|
||||
throw new BadRequestError(message);
|
||||
};
|
||||
|
||||
const requestBodyValue = (event, name) => {
|
||||
const body = event.requestInfo().body;
|
||||
return body && typeof body.get === "function" ? body.get(name) : body ? body[name] : undefined;
|
||||
};
|
||||
|
||||
const validateWidgetManifest = (content) => {
|
||||
let widget;
|
||||
try {
|
||||
widget = JSON.parse(content);
|
||||
} catch {
|
||||
rejectRequest("Widget content must be valid JSON");
|
||||
}
|
||||
if (!widget || Array.isArray(widget) || typeof widget !== "object") rejectRequest("Widget must be a JSON object");
|
||||
if (widget.$schema !== CUSTOM_WIDGET_SCHEMA) rejectRequest("Widget schema is not supported");
|
||||
if (typeof widget.name !== "string" || !widget.name.trim() || widget.name.length > 100)
|
||||
rejectRequest("Widget name is invalid");
|
||||
if (!widget.sources || Array.isArray(widget.sources) || typeof widget.sources !== "object" || !widget.sources.default)
|
||||
rejectRequest("Widget must define a default source");
|
||||
if (Object.keys(widget.sources).length > 8) rejectRequest("Widget has too many sources");
|
||||
for (const source of Object.values(widget.sources)) {
|
||||
if (!source || Array.isArray(source) || typeof source !== "object") rejectRequest("Widget source is invalid");
|
||||
if (typeof source.baseUrl !== "string" || !/^https?:\/\//.test(source.baseUrl))
|
||||
rejectRequest("Widget source URL is invalid");
|
||||
if (!["public", "private", "loopback"].includes(source.networkScope))
|
||||
rejectRequest("Widget source network scope is invalid");
|
||||
}
|
||||
if (!widget.requests || Array.isArray(widget.requests) || typeof widget.requests !== "object")
|
||||
rejectRequest("Widget requests are invalid");
|
||||
if (!widget.options || Array.isArray(widget.options) || typeof widget.options !== "object")
|
||||
rejectRequest("Widget options are invalid");
|
||||
if (typeof widget.template !== "string" || !widget.template.trim()) rejectRequest("Widget template is invalid");
|
||||
return JSON.stringify(widget);
|
||||
};
|
||||
|
||||
const validateAndNormalizeSubmission = (record) => {
|
||||
const type = record.getString("type");
|
||||
const content = record.getString("content");
|
||||
if (!content || content.length > MAX_CONTENT_LENGTH) rejectRequest("Submission content is invalid");
|
||||
record.set("title", record.getString("title").trim());
|
||||
record.set("description", record.getString("description").trim());
|
||||
record.set("changelog", record.getString("changelog").trim());
|
||||
if (type === "customCss") {
|
||||
if (!content.trim() || content.length > MAX_CSS_LENGTH) rejectRequest("Custom CSS is empty or too large");
|
||||
record.set("widgetSchema", CUSTOM_CSS_SCHEMA);
|
||||
return;
|
||||
}
|
||||
if (type !== "customWidget") rejectRequest("Submission type is invalid");
|
||||
record.set("content", validateWidgetManifest(content));
|
||||
record.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
|
||||
};
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const emailTemplate = (filename) => {
|
||||
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || __hooks;
|
||||
return $os.readFile($filepath.join(hooksPath, filename));
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
emailTemplate,
|
||||
escapeHtml,
|
||||
requestBodyValue,
|
||||
sendEmail,
|
||||
validateAndNormalizeSubmission,
|
||||
};
|
||||
@@ -1,65 +1,5 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
const CUSTOM_CSS_SCHEMA = "homarr-custom-css-v1";
|
||||
const MAX_CSS_LENGTH = 16_384;
|
||||
const MAX_CONTENT_LENGTH = 1_000_000;
|
||||
|
||||
const rejectRequest = (message) => {
|
||||
throw new BadRequestError(message);
|
||||
};
|
||||
|
||||
const requestBodyValue = (event, name) => {
|
||||
const body = event.requestInfo().body;
|
||||
return body && typeof body.get === "function" ? body.get(name) : body ? body[name] : undefined;
|
||||
};
|
||||
|
||||
const validateWidgetManifest = (content) => {
|
||||
let widget;
|
||||
try {
|
||||
widget = JSON.parse(content);
|
||||
} catch {
|
||||
rejectRequest("Widget content must be valid JSON");
|
||||
}
|
||||
if (!widget || Array.isArray(widget) || typeof widget !== "object") rejectRequest("Widget must be a JSON object");
|
||||
if (widget.$schema !== CUSTOM_WIDGET_SCHEMA) rejectRequest("Widget schema is not supported");
|
||||
if (typeof widget.name !== "string" || !widget.name.trim() || widget.name.length > 100)
|
||||
rejectRequest("Widget name is invalid");
|
||||
if (!widget.sources || Array.isArray(widget.sources) || typeof widget.sources !== "object" || !widget.sources.default)
|
||||
rejectRequest("Widget must define a default source");
|
||||
if (Object.keys(widget.sources).length > 8) rejectRequest("Widget has too many sources");
|
||||
for (const source of Object.values(widget.sources)) {
|
||||
if (!source || Array.isArray(source) || typeof source !== "object") rejectRequest("Widget source is invalid");
|
||||
if (typeof source.baseUrl !== "string" || !/^https?:\/\//.test(source.baseUrl))
|
||||
rejectRequest("Widget source URL is invalid");
|
||||
if (!["public", "private", "loopback"].includes(source.networkScope))
|
||||
rejectRequest("Widget source network scope is invalid");
|
||||
}
|
||||
if (!widget.requests || Array.isArray(widget.requests) || typeof widget.requests !== "object")
|
||||
rejectRequest("Widget requests are invalid");
|
||||
if (!widget.options || Array.isArray(widget.options) || typeof widget.options !== "object")
|
||||
rejectRequest("Widget options are invalid");
|
||||
if (typeof widget.template !== "string" || !widget.template.trim()) rejectRequest("Widget template is invalid");
|
||||
return JSON.stringify(widget);
|
||||
};
|
||||
|
||||
const validateAndNormalizeSubmission = (record) => {
|
||||
const type = record.getString("type");
|
||||
const content = record.getString("content");
|
||||
if (!content || content.length > MAX_CONTENT_LENGTH) rejectRequest("Submission content is invalid");
|
||||
record.set("title", record.getString("title").trim());
|
||||
record.set("description", record.getString("description").trim());
|
||||
record.set("changelog", record.getString("changelog").trim());
|
||||
if (type === "customCss") {
|
||||
if (!content.trim() || content.length > MAX_CSS_LENGTH) rejectRequest("Custom CSS is empty or too large");
|
||||
record.set("widgetSchema", CUSTOM_CSS_SCHEMA);
|
||||
return;
|
||||
}
|
||||
if (type !== "customWidget") rejectRequest("Submission type is invalid");
|
||||
record.set("content", validateWidgetManifest(content));
|
||||
record.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
|
||||
};
|
||||
|
||||
onBootstrap((event) => {
|
||||
event.next();
|
||||
const users = event.app.findCollectionByNameOrId("users");
|
||||
@@ -72,6 +12,7 @@ onBootstrap((event) => {
|
||||
});
|
||||
|
||||
onRecordCreateRequest((event) => {
|
||||
const { validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
|
||||
validateAndNormalizeSubmission(event.record);
|
||||
event.record.set("revision", 1);
|
||||
event.record.set("changelog", "");
|
||||
@@ -80,6 +21,7 @@ onRecordCreateRequest((event) => {
|
||||
}, "submissions");
|
||||
|
||||
onRecordUpdateRequest((event) => {
|
||||
const { requestBodyValue, validateAndNormalizeSubmission } = require(`${__hooks}/workshop-utils.js`);
|
||||
const original = event.record.original();
|
||||
const currentRevision = original.getInt("revision");
|
||||
const expectedRevision = Number(requestBodyValue(event, "expectedRevision"));
|
||||
@@ -110,34 +52,9 @@ onRecordAfterCreateSuccess((event) => {
|
||||
event.next();
|
||||
}, "submissions");
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const emailTemplate = (filename) => {
|
||||
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
|
||||
return $os.readFile($filepath.join(hooksPath, filename));
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
onRecordAfterCreateSuccess((event) => {
|
||||
try {
|
||||
const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
|
||||
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"));
|
||||
@@ -174,6 +91,7 @@ onRecordAfterCreateSuccess((event) => {
|
||||
|
||||
onRecordAfterCreateSuccess((event) => {
|
||||
try {
|
||||
const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
|
||||
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(/\/$/, "");
|
||||
@@ -218,6 +136,7 @@ onRecordAfterCreateSuccess((event) => {
|
||||
}, "reports");
|
||||
|
||||
onRecordDeleteRequest((event) => {
|
||||
const { emailTemplate, escapeHtml, sendEmail } = require(`${__hooks}/workshop-utils.js`);
|
||||
const submissionId = event.record.id;
|
||||
const submissionTitle = event.record.getString("title");
|
||||
const authorId = event.record.get("author");
|
||||
|
||||
@@ -52,7 +52,7 @@ done
|
||||
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml stop workshop
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
printf 'y\n' | PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
|
||||
migrate down 1 --dir=/pb_data --migrationsDir=/pb_migrations
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
|
||||
@@ -9,6 +9,10 @@ if (typeof clientExport !== "string") throw new Error("Workshop client export is
|
||||
await access(resolve("packages/workshop", clientExport));
|
||||
|
||||
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
|
||||
await access(resolve("apps/workshop/pb_hooks/workshop-utils.js"));
|
||||
if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
|
||||
throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts");
|
||||
}
|
||||
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
|
||||
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { openApiDocument } from "@homarr/api/open-api";
|
||||
import { openApiDocument } from "../packages/api/src/open-api";
|
||||
|
||||
const baseUrl = "http://localhost:3000";
|
||||
const generated = JSON.parse(JSON.stringify(openApiDocument(baseUrl))) as unknown;
|
||||
|
||||
Reference in new issue
Block a user