fix(custom-widgets): refine v2 authoring
This commit is contained in:
124 files changed
+2089
-913
No files matched your search
@@ -12,6 +12,14 @@ PB_EXPOSE_PORT=18090 docker compose -f apps/workshop/docker-compose.yml up --bui
|
||||
|
||||
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, configure GitHub OAuth on the `users` collection, and add Workshop administrators through the `workshop_admins` collection.
|
||||
|
||||
The GitHub OAuth application needs one callback for the Workshop host:
|
||||
|
||||
```text
|
||||
https://<workshop-host>/api/oauth2-redirect
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Run the disposable service integration test from the repository root:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -13,8 +13,6 @@ const TOP_LEVEL_FIELDS = [
|
||||
"requests",
|
||||
"optionsSchema",
|
||||
"defaultOptions",
|
||||
"stateSchema",
|
||||
"defaultState",
|
||||
"template",
|
||||
];
|
||||
const SOURCE_FIELDS = ["id", "name", "baseUrl", "networkScope", "auth"];
|
||||
@@ -452,22 +450,6 @@ function collectDynamicOptionRequestIds(schema, result) {
|
||||
if (schema[branch] !== undefined) collectDynamicOptionRequestIds(schema[branch], result);
|
||||
}
|
||||
|
||||
function validateDefaultState(schema, state) {
|
||||
if (!isObject(state)) bad("Widget default state is invalid");
|
||||
for (const [key, value] of Object.entries(state)) {
|
||||
const type = schema[key];
|
||||
const matches =
|
||||
type === "date"
|
||||
? typeof value === "string"
|
||||
: type === "string[]" || type === "date[]"
|
||||
? Array.isArray(value) && value.every((entry) => typeof entry === "string")
|
||||
: type === "number[]"
|
||||
? Array.isArray(value) && value.every((entry) => typeof entry === "number")
|
||||
: typeof value === type;
|
||||
if (!type || !matches) bad("Widget default state does not match its schema");
|
||||
}
|
||||
}
|
||||
|
||||
function containsCredential(value, key) {
|
||||
if (SENSITIVE_KEY.test(key || "") && typeof value === "string" && value.trim()) return true;
|
||||
if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, ""));
|
||||
@@ -488,7 +470,7 @@ function containsPrototypeKey(value) {
|
||||
function validateTemplate(template) {
|
||||
if (typeof template !== "string" || !template.trim() || template.length > 50000) bad("Widget template is invalid");
|
||||
if (
|
||||
/(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s)/i.test(
|
||||
/(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s|\{[^{}]*\bstate(?:\.|\[))/i.test(
|
||||
template,
|
||||
)
|
||||
)
|
||||
@@ -509,14 +491,13 @@ function validateTemplateRequests(template, requests) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemplateStateBindings(template, stateSchema) {
|
||||
function validateTemplateBindings(template) {
|
||||
const bindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/g;
|
||||
let match;
|
||||
while ((match = bindingPattern.exec(template)) !== null) {
|
||||
const stateName = match[1] || match[2];
|
||||
if (!stateName) bad("bind must use a literal state name");
|
||||
if (!Object.prototype.hasOwnProperty.call(stateSchema, stateName))
|
||||
bad(`bind references unknown state '${stateName}'`);
|
||||
const inputName = match[1] || match[2];
|
||||
if (!inputName) bad("bind must use a literal input name");
|
||||
if (!validBindingId(inputName)) bad("bind must use a valid input name");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,21 +576,9 @@ function validateSubmission(record) {
|
||||
const request = widget.requests.find((candidate) => candidate.id === requestId);
|
||||
if (!request || request.kind !== "query") bad("Widget dynamic options must reference a query request");
|
||||
}
|
||||
if (
|
||||
widget.stateSchema !== undefined &&
|
||||
(!isObject(widget.stateSchema) ||
|
||||
Object.entries(widget.stateSchema).some(
|
||||
([key, type]) =>
|
||||
!validBindingId(key) ||
|
||||
SENSITIVE_KEY.test(key) ||
|
||||
!["string", "number", "boolean", "date", "string[]", "number[]", "date[]"].includes(type),
|
||||
))
|
||||
)
|
||||
bad("Widget state schema is invalid");
|
||||
if (widget.defaultState !== undefined) validateDefaultState(widget.stateSchema || {}, widget.defaultState);
|
||||
validateTemplate(widget.template);
|
||||
validateTemplateRequests(widget.template, widget.requests);
|
||||
validateTemplateStateBindings(widget.template, widget.stateSchema || {});
|
||||
validateTemplateBindings(widget.template);
|
||||
for (const request of widget.requests) {
|
||||
for (const [name, value] of Object.entries(request.staticHeaders || {})) {
|
||||
if ((SENSITIVE_KEY.test(name) && String(value).trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(String(value)))
|
||||
@@ -619,7 +588,6 @@ function validateSubmission(record) {
|
||||
if (
|
||||
CREDENTIAL_TEXT.test(widget.template) ||
|
||||
containsCredential(widget.defaultOptions, "") ||
|
||||
containsCredential(widget.defaultState, "") ||
|
||||
widget.requests.some(
|
||||
(request) => containsCredential(request.bodyTemplate, "") || containsCredential(request.queryTemplate, ""),
|
||||
)
|
||||
@@ -628,7 +596,6 @@ function validateSubmission(record) {
|
||||
if (
|
||||
containsPrototypeKey(widget.optionsSchema) ||
|
||||
containsPrototypeKey(widget.defaultOptions) ||
|
||||
containsPrototypeKey(widget.defaultState) ||
|
||||
widget.requests.some(
|
||||
(request) => containsPrototypeKey(request.queryTemplate) || containsPrototypeKey(request.bodyTemplate),
|
||||
)
|
||||
|
||||
@@ -83,8 +83,6 @@ const widget = {
|
||||
requests: [],
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
stateSchema: {},
|
||||
defaultState: {},
|
||||
template: "<Text>Runtime probe</Text>",
|
||||
};
|
||||
const submission = await request("/api/collections/submissions/records", {
|
||||
|
||||
Reference in new issue
Block a user