simplify v2 migration history
This commit is contained in:
1 parent
6ae109e8f5
commit
7a2d6009f7
34 files changed
+725
-9091
No files matched your search
@@ -42,6 +42,10 @@ pnpm dev
|
||||
Homarr is available at `http://127.0.0.1:3000`. The foreground infrastructure command starts Redis and the optional
|
||||
database services from `development/development.docker-compose.yml`; stop it with Ctrl+C after stopping Homarr.
|
||||
|
||||
The unreleased v2 Custom Widget migration history is intentionally consolidated before release. A development database
|
||||
that already applied the earlier `custom_widget_v2_reset` migration is unsupported: back up anything you need, reset
|
||||
only that disposable database, and run the migration command again. Homarr never deletes or resets it automatically.
|
||||
|
||||
## Run PocketBase and documentation locally
|
||||
|
||||
The integrated command starts persistent PocketBase on port 8090, waits for its health endpoint and Workshop
|
||||
|
||||
@@ -44,6 +44,13 @@ docker compose -f apps/workshop/docker-compose.yml logs -f workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml stop workshop
|
||||
```
|
||||
|
||||
### Pre-release migration reset
|
||||
|
||||
The v2 branch consolidates the unreleased Workshop migrations into one initial migration. A PocketBase volume that ran
|
||||
an earlier v2 Workshop migration chain is unsupported. Back up anything you need, then use the intentional local reset
|
||||
command near the end of this document and start the stack again. Startup never deletes a volume automatically. This
|
||||
exception applies only to disposable pre-release volumes and must not be used for production data.
|
||||
|
||||
## Connect the Homarr application
|
||||
|
||||
Use the same URL contract in the repository-root `.env`, then restart Homarr:
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||
const cloneRule = (value) => (value === null ? null : String(value));
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
const identityFieldsUnchanged = [
|
||||
"@request.body.email:changed = false",
|
||||
"@request.body.displayName:changed = false",
|
||||
"@request.body.avatarUrl:changed = false",
|
||||
"@request.body.githubUsername:changed = false",
|
||||
"@request.body.githubProfileUrl:changed = false",
|
||||
].join(" && ");
|
||||
const immutableSubmissionFields = [
|
||||
"@request.body.author:changed = false",
|
||||
"@request.body.type:changed = false",
|
||||
"@request.body.widgetSchema:changed = false",
|
||||
].join(" && ");
|
||||
const moderatorSubmissionFieldsUnchanged = [
|
||||
"@request.body.title:changed = false",
|
||||
"@request.body.description:changed = false",
|
||||
"@request.body.content:changed = false",
|
||||
"@request.body.changelog:changed = false",
|
||||
"@request.body.screenshots:changed = false",
|
||||
].join(" && ");
|
||||
const workshopRateLimits = [
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "submissions:update", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "submissions:delete", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "votes:update", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "votes:delete", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "comments:update", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "comments:delete", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
{ label: "reports:update", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "reports:delete", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "users:update", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
];
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
@@ -63,11 +98,10 @@ migrate(
|
||||
addUserField(new URLField({ name: "githubProfileUrl" }));
|
||||
addUserField(new BoolField({ name: "isAdmin" }));
|
||||
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
users.listRule = "";
|
||||
users.viewRule = "";
|
||||
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
|
||||
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
|
||||
users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
|
||||
users.deleteRule = null;
|
||||
app.save(users);
|
||||
const stateRecord = new Record(stateCollection);
|
||||
@@ -80,7 +114,9 @@ migrate(
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
|
||||
updateRule: `(author = @request.auth.id || ${adminRule}) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false`,
|
||||
updateRule:
|
||||
`(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
|
||||
`(${adminRule} && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`,
|
||||
deleteRule: `author = @request.auth.id || ${adminRule}`,
|
||||
fields: [
|
||||
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["customWidget", "customCss"] },
|
||||
@@ -183,7 +219,12 @@ migrate(
|
||||
listRule: adminRule,
|
||||
viewRule: adminRule,
|
||||
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
|
||||
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
|
||||
updateRule:
|
||||
`${adminRule} && ` +
|
||||
"@request.body.reporter:changed = false && " +
|
||||
"@request.body.submission:changed = false && " +
|
||||
"@request.body.category:changed = false && " +
|
||||
"@request.body.explanation:changed = false",
|
||||
deleteRule: adminRule,
|
||||
fields: [
|
||||
{
|
||||
@@ -243,12 +284,9 @@ migrate(
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...settings.rateLimits.rules.filter(
|
||||
(rule) => !["submissions:create", "votes:create", "comments:create", "reports:create"].includes(rule.label),
|
||||
(rule) => !workshopRateLimits.some((workshopRule) => workshopRule.label === rule.label),
|
||||
),
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
...workshopRateLimits,
|
||||
];
|
||||
app.save(settings);
|
||||
},
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const managedLabels = [
|
||||
"submissions:create",
|
||||
"submissions:update",
|
||||
"submissions:delete",
|
||||
"votes:create",
|
||||
"votes:update",
|
||||
"votes:delete",
|
||||
"comments:create",
|
||||
"comments:update",
|
||||
"comments:delete",
|
||||
"reports:create",
|
||||
"reports:update",
|
||||
"reports:delete",
|
||||
"users:update",
|
||||
];
|
||||
const legacyCreateRules = [
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
];
|
||||
const legacyCreateLabels = legacyCreateRules.map((rule) => rule.label);
|
||||
|
||||
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||
const withoutManagedRules = (rules) => rules.filter((rule) => !managedLabels.includes(rule.label));
|
||||
const managedRules = (rules) => rules.filter((rule) => managedLabels.includes(rule.label));
|
||||
const deriveLegacyWriteRateLimitState = (snapshot) => ({
|
||||
enabled: true,
|
||||
managedRules: managedRules([
|
||||
...snapshot.rateLimits.rules.filter((rule) => !legacyCreateLabels.includes(rule.label)),
|
||||
...legacyCreateRules,
|
||||
]),
|
||||
});
|
||||
|
||||
const findMigrationState = (app) => {
|
||||
try {
|
||||
const collection = app.findCollectionByNameOrId("workshop_migration_state");
|
||||
const record = app.findFirstRecordByFilter(collection.id, "");
|
||||
const snapshot = JSON.parse(record.getString("snapshot"));
|
||||
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
||||
throw new Error("Workshop migration state snapshot is invalid");
|
||||
}
|
||||
return { record, snapshot };
|
||||
} catch {
|
||||
throw new Error("Workshop write rate-limit migration state is unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const settings = app.settings();
|
||||
const state = findMigrationState(app);
|
||||
state.snapshot.writeRateLimits = {
|
||||
enabled: settings.rateLimits.enabled,
|
||||
managedRules: cloneJson(managedRules(settings.rateLimits.rules)),
|
||||
};
|
||||
state.record.set("snapshot", JSON.stringify(state.snapshot));
|
||||
app.save(state.record);
|
||||
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...withoutManagedRules(settings.rateLimits.rules),
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "submissions:update", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "submissions:delete", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "votes:update", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "votes:delete", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "comments:update", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "comments:delete", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
{ label: "reports:update", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "reports:delete", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "users:update", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
];
|
||||
app.save(settings);
|
||||
},
|
||||
(app) => {
|
||||
const settings = app.settings();
|
||||
const snapshot = findMigrationState(app).snapshot;
|
||||
const state = snapshot.writeRateLimits || deriveLegacyWriteRateLimitState(snapshot);
|
||||
if (!state || typeof state.enabled !== "boolean" || !Array.isArray(state.managedRules)) {
|
||||
throw new Error("Workshop write rate-limit rollback snapshot is missing or invalid");
|
||||
}
|
||||
settings.rateLimits.enabled = state.enabled;
|
||||
settings.rateLimits.rules = [...withoutManagedRules(settings.rateLimits.rules), ...cloneJson(state.managedRules)];
|
||||
app.save(settings);
|
||||
},
|
||||
);
|
||||
@@ -1,65 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const legacyUserUpdateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
|
||||
const legacySubmissionUpdateRule =
|
||||
"(author = @request.auth.id || @request.auth.isAdmin = true) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false";
|
||||
const legacyReportUpdateRule =
|
||||
"@request.auth.isAdmin = true && @request.body.reporter:changed = false && @request.body.submission:changed = false";
|
||||
|
||||
const identityFieldsUnchanged = [
|
||||
"@request.body.email:changed = false",
|
||||
"@request.body.displayName:changed = false",
|
||||
"@request.body.avatarUrl:changed = false",
|
||||
"@request.body.githubUsername:changed = false",
|
||||
"@request.body.githubProfileUrl:changed = false",
|
||||
].join(" && ");
|
||||
|
||||
const immutableSubmissionFields = [
|
||||
"@request.body.author:changed = false",
|
||||
"@request.body.type:changed = false",
|
||||
"@request.body.widgetSchema:changed = false",
|
||||
].join(" && ");
|
||||
|
||||
const moderatorSubmissionFieldsUnchanged = [
|
||||
"@request.body.title:changed = false",
|
||||
"@request.body.description:changed = false",
|
||||
"@request.body.content:changed = false",
|
||||
"@request.body.changelog:changed = false",
|
||||
"@request.body.screenshots:changed = false",
|
||||
].join(" && ");
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
|
||||
app.save(users);
|
||||
|
||||
const submissions = app.findCollectionByNameOrId("submissions");
|
||||
submissions.updateRule =
|
||||
`(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
|
||||
`(@request.auth.isAdmin = true && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`;
|
||||
app.save(submissions);
|
||||
|
||||
const reports = app.findCollectionByNameOrId("reports");
|
||||
reports.updateRule =
|
||||
"@request.auth.isAdmin = true && " +
|
||||
"@request.body.reporter:changed = false && " +
|
||||
"@request.body.submission:changed = false && " +
|
||||
"@request.body.category:changed = false && " +
|
||||
"@request.body.explanation:changed = false";
|
||||
app.save(reports);
|
||||
},
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
users.updateRule = legacyUserUpdateRule;
|
||||
app.save(users);
|
||||
|
||||
const submissions = app.findCollectionByNameOrId("submissions");
|
||||
submissions.updateRule = legacySubmissionUpdateRule;
|
||||
app.save(submissions);
|
||||
|
||||
const reports = app.findCollectionByNameOrId("reports");
|
||||
reports.updateRule = legacyReportUpdateRule;
|
||||
app.save(reports);
|
||||
},
|
||||
);
|
||||
@@ -1,61 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const managedLabels = [
|
||||
"submissions:create",
|
||||
"submissions:update",
|
||||
"submissions:delete",
|
||||
"votes:create",
|
||||
"votes:update",
|
||||
"votes:delete",
|
||||
"comments:create",
|
||||
"comments:update",
|
||||
"comments:delete",
|
||||
"reports:create",
|
||||
"reports:update",
|
||||
"reports:delete",
|
||||
"users:update",
|
||||
];
|
||||
const legacyCreateRules = [
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
];
|
||||
const legacyCreateLabels = legacyCreateRules.map((rule) => rule.label);
|
||||
const managedRules = (rules) => rules.filter((rule) => managedLabels.includes(rule.label));
|
||||
|
||||
const findMigrationState = (app) => {
|
||||
try {
|
||||
const collection = app.findCollectionByNameOrId("workshop_migration_state");
|
||||
const record = app.findFirstRecordByFilter(collection.id, "");
|
||||
const snapshot = JSON.parse(record.getString("snapshot"));
|
||||
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
||||
throw new Error("Workshop migration state snapshot is invalid");
|
||||
}
|
||||
return { record, snapshot };
|
||||
} catch {
|
||||
throw new Error("Workshop rate-limit compatibility state is unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const state = findMigrationState(app);
|
||||
if (state.snapshot.writeRateLimits) return;
|
||||
if (!state.snapshot.rateLimits || !Array.isArray(state.snapshot.rateLimits.rules)) {
|
||||
throw new Error("Workshop legacy rate-limit snapshot is missing or invalid");
|
||||
}
|
||||
state.snapshot.writeRateLimits = {
|
||||
enabled: true,
|
||||
managedRules: managedRules([
|
||||
...state.snapshot.rateLimits.rules.filter((rule) => !legacyCreateLabels.includes(rule.label)),
|
||||
...legacyCreateRules,
|
||||
]),
|
||||
};
|
||||
state.record.set("snapshot", JSON.stringify(state.snapshot));
|
||||
app.save(state.record);
|
||||
},
|
||||
() => {
|
||||
// Keep the backfill until 1784240001 is rolled back; 1784240000 removes the state collection afterward.
|
||||
},
|
||||
);
|
||||
@@ -13,7 +13,6 @@ ROLLBACK_CONTAINER=""
|
||||
|
||||
node apps/workshop/tests/workshop-contracts.mjs
|
||||
node apps/workshop/tests/workshop-migration.mjs
|
||||
node apps/workshop/tests/workshop-rate-limit-migration.mjs
|
||||
node apps/workshop/tests/entrypoint-url.mjs
|
||||
node apps/workshop/tests/workshop-identity.mjs
|
||||
node apps/workshop/tests/workshop-oauth-hook.mjs
|
||||
|
||||
@@ -97,34 +97,13 @@ for (const required of [
|
||||
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
|
||||
}
|
||||
|
||||
const hardeningMigration = await read("apps/workshop/pb_migrations/1784240001_workshop_write_rate_limits.js");
|
||||
for (const operation of ["create", "update", "delete"]) {
|
||||
for (const collection of ["submissions", "votes", "comments", "reports"]) {
|
||||
const label = `${collection}:${operation}`;
|
||||
if (!hardeningMigration.includes(label)) throw new Error(`Workshop write rate limit is missing: ${label}`);
|
||||
}
|
||||
}
|
||||
if (!hardeningMigration.includes("users:update")) throw new Error("Workshop user file updates must be rate limited");
|
||||
for (const required of [
|
||||
"writeRateLimits",
|
||||
"managedRules(settings.rateLimits.rules)",
|
||||
"settings.rateLimits.enabled = state.enabled",
|
||||
"...cloneJson(state.managedRules)",
|
||||
]) {
|
||||
if (!hardeningMigration.includes(required)) {
|
||||
throw new Error(`Workshop write rate-limit rollback is missing persistent state restoration: ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
const accessHardeningMigration = await read("apps/workshop/pb_migrations/1784240002_workshop_access_hardening.js");
|
||||
const rateLimitCompatibilityMigration = await read(
|
||||
"apps/workshop/pb_migrations/1784240003_workshop_rate_limit_rollback_compatibility.js",
|
||||
);
|
||||
for (const required of ["writeRateLimits", "legacyCreateRules", "rate-limit compatibility state is unavailable"]) {
|
||||
if (!rateLimitCompatibilityMigration.includes(required)) {
|
||||
throw new Error(`Workshop rate-limit compatibility migration is missing: ${required}`);
|
||||
if (!migration.includes(label)) throw new Error(`Workshop write rate limit is missing: ${label}`);
|
||||
}
|
||||
}
|
||||
if (!migration.includes("users:update")) throw new Error("Workshop user file updates must be rate limited");
|
||||
for (const protectedField of [
|
||||
"email:changed",
|
||||
"displayName:changed",
|
||||
@@ -132,11 +111,11 @@ for (const protectedField of [
|
||||
"githubUsername:changed",
|
||||
"githubProfileUrl:changed",
|
||||
]) {
|
||||
if (!accessHardeningMigration.includes(protectedField)) {
|
||||
if (!migration.includes(protectedField)) {
|
||||
throw new Error(`Workshop OAuth identity field remains caller-controlled: ${protectedField}`);
|
||||
}
|
||||
}
|
||||
if (accessHardeningMigration.includes("@request.body.avatar:changed")) {
|
||||
if (migration.includes("@request.body.avatar:changed")) {
|
||||
throw new Error("Workshop access rules must not use the unsupported :changed modifier for file fields");
|
||||
}
|
||||
if (
|
||||
@@ -153,12 +132,12 @@ for (const protectedField of [
|
||||
"changelog:changed",
|
||||
"screenshots:changed",
|
||||
]) {
|
||||
if (!accessHardeningMigration.includes(protectedField)) {
|
||||
if (!migration.includes(protectedField)) {
|
||||
throw new Error(`Workshop moderators can still rewrite submissions: ${protectedField}`);
|
||||
}
|
||||
}
|
||||
for (const protectedField of ["category:changed", "explanation:changed"]) {
|
||||
if (!accessHardeningMigration.includes(protectedField)) {
|
||||
if (!migration.includes(protectedField)) {
|
||||
throw new Error(`Workshop moderators can still rewrite reports: ${protectedField}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
|
||||
const normalized = (value) => JSON.parse(JSON.stringify(value));
|
||||
const managedLabels = [
|
||||
"submissions:create",
|
||||
"submissions:update",
|
||||
"submissions:delete",
|
||||
"votes:create",
|
||||
"votes:update",
|
||||
"votes:delete",
|
||||
"comments:create",
|
||||
"comments:update",
|
||||
"comments:delete",
|
||||
"reports:create",
|
||||
"reports:update",
|
||||
"reports:delete",
|
||||
"users:update",
|
||||
];
|
||||
const originalManagedRules = managedLabels.map((label, index) => ({
|
||||
label,
|
||||
audience: index % 2 === 0 ? "@auth" : "",
|
||||
duration: index + 1,
|
||||
maxRequests: index + 10,
|
||||
}));
|
||||
const originalUnmanagedRule = {
|
||||
label: "unrelated:create",
|
||||
audience: "",
|
||||
duration: 41,
|
||||
maxRequests: 42,
|
||||
};
|
||||
const stateCollection = { id: "workshop_migration_state" };
|
||||
let stateSnapshot = JSON.stringify({ marker: "retain-existing-migration-state" });
|
||||
const stateRecord = {
|
||||
getString(name) {
|
||||
assert.equal(name, "snapshot");
|
||||
return stateSnapshot;
|
||||
},
|
||||
set(name, value) {
|
||||
assert.equal(name, "snapshot");
|
||||
stateSnapshot = value;
|
||||
},
|
||||
};
|
||||
const settings = {
|
||||
rateLimits: {
|
||||
enabled: false,
|
||||
rules: [originalUnmanagedRule, ...originalManagedRules],
|
||||
},
|
||||
};
|
||||
const app = {
|
||||
findCollectionByNameOrId(name) {
|
||||
if (name !== stateCollection.id) throw new Error(`Missing collection ${name}`);
|
||||
return stateCollection;
|
||||
},
|
||||
findFirstRecordByFilter(collectionId) {
|
||||
if (collectionId !== stateCollection.id) throw new Error(`Missing state record ${collectionId}`);
|
||||
return stateRecord;
|
||||
},
|
||||
save() {},
|
||||
settings() {
|
||||
return settings;
|
||||
},
|
||||
};
|
||||
|
||||
const loadMigration = async (path) => {
|
||||
let loaded;
|
||||
const source = await readFile(path, "utf8");
|
||||
vm.runInNewContext(source, {
|
||||
console,
|
||||
migrate: (up, down) => {
|
||||
loaded = { up, down };
|
||||
},
|
||||
});
|
||||
if (!loaded) throw new Error(`Workshop migration did not register: ${path}`);
|
||||
return loaded;
|
||||
};
|
||||
|
||||
const migration = await loadMigration("apps/workshop/pb_migrations/1784240001_workshop_write_rate_limits.js");
|
||||
|
||||
migration.up(app);
|
||||
const savedState = JSON.parse(stateSnapshot);
|
||||
assert.equal(savedState.marker, "retain-existing-migration-state");
|
||||
assert.deepEqual(savedState.writeRateLimits, {
|
||||
enabled: false,
|
||||
managedRules: originalManagedRules,
|
||||
});
|
||||
assert.equal(settings.rateLimits.enabled, true);
|
||||
assert.deepEqual(
|
||||
normalized(settings.rateLimits.rules.filter((rule) => !managedLabels.includes(rule.label))),
|
||||
[originalUnmanagedRule],
|
||||
"Applying Workshop rate limits changed unrelated rules",
|
||||
);
|
||||
assert.deepEqual(
|
||||
new Set(settings.rateLimits.rules.map((rule) => rule.label)),
|
||||
new Set([...managedLabels, originalUnmanagedRule.label]),
|
||||
"Applying Workshop rate limits did not replace every managed label",
|
||||
);
|
||||
|
||||
const laterUnmanagedRule = {
|
||||
label: "added-after-migration:update",
|
||||
audience: "@auth",
|
||||
duration: 51,
|
||||
maxRequests: 52,
|
||||
};
|
||||
settings.rateLimits.rules = [
|
||||
laterUnmanagedRule,
|
||||
...settings.rateLimits.rules.filter((rule) => managedLabels.includes(rule.label)),
|
||||
];
|
||||
migration.down(app);
|
||||
|
||||
assert.equal(settings.rateLimits.enabled, false, "Rollback did not restore the original enabled flag");
|
||||
assert.deepEqual(
|
||||
normalized(settings.rateLimits.rules.filter((rule) => !managedLabels.includes(rule.label))),
|
||||
[laterUnmanagedRule],
|
||||
"Rollback changed unrelated rules added after the migration",
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalized(settings.rateLimits.rules.filter((rule) => managedLabels.includes(rule.label))),
|
||||
originalManagedRules,
|
||||
"Rollback did not restore every original managed-label rule",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
migration.up({
|
||||
...app,
|
||||
findCollectionByNameOrId() {
|
||||
throw new Error("missing");
|
||||
},
|
||||
}),
|
||||
/migration state is unavailable/u,
|
||||
"Migration must fail closed when its persistent rollback snapshot is unavailable",
|
||||
);
|
||||
|
||||
const legacyOriginalRules = [
|
||||
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
|
||||
{ label: "submissions:create", audience: "", duration: 73, maxRequests: 74 },
|
||||
{ label: "comments:update", audience: "@auth", duration: 75, maxRequests: 76 },
|
||||
];
|
||||
const legacyStateCollection = { id: "workshop_migration_state" };
|
||||
let legacyStateSnapshot = JSON.stringify({
|
||||
marker: "already-applied-old-0001",
|
||||
rateLimits: { enabled: false, rules: legacyOriginalRules },
|
||||
});
|
||||
const legacyStateRecord = {
|
||||
getString() {
|
||||
return legacyStateSnapshot;
|
||||
},
|
||||
set(_name, value) {
|
||||
legacyStateSnapshot = value;
|
||||
},
|
||||
};
|
||||
const legacySettings = {
|
||||
rateLimits: {
|
||||
enabled: true,
|
||||
rules: [
|
||||
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
|
||||
...managedLabels.map((label) => ({ label, audience: "@auth", duration: 60, maxRequests: 30 })),
|
||||
],
|
||||
},
|
||||
};
|
||||
const legacyApp = {
|
||||
findCollectionByNameOrId() {
|
||||
return legacyStateCollection;
|
||||
},
|
||||
findFirstRecordByFilter() {
|
||||
return legacyStateRecord;
|
||||
},
|
||||
save() {},
|
||||
settings() {
|
||||
return legacySettings;
|
||||
},
|
||||
};
|
||||
const compatibilityMigration = await loadMigration(
|
||||
"apps/workshop/pb_migrations/1784240003_workshop_rate_limit_rollback_compatibility.js",
|
||||
);
|
||||
compatibilityMigration.up(legacyApp);
|
||||
compatibilityMigration.down(legacyApp);
|
||||
|
||||
const backfilledState = JSON.parse(legacyStateSnapshot);
|
||||
assert.equal(backfilledState.marker, "already-applied-old-0001");
|
||||
assert.deepEqual(backfilledState.writeRateLimits, {
|
||||
enabled: true,
|
||||
managedRules: [
|
||||
{ label: "comments:update", audience: "@auth", duration: 75, maxRequests: 76 },
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
],
|
||||
});
|
||||
|
||||
migration.down(legacyApp);
|
||||
assert.equal(legacySettings.rateLimits.enabled, true);
|
||||
assert.deepEqual(
|
||||
normalized(legacySettings.rateLimits.rules),
|
||||
[
|
||||
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
|
||||
...backfilledState.writeRateLimits.managedRules,
|
||||
],
|
||||
"An already-applied legacy migration could not be rolled back after the compatibility backfill",
|
||||
);
|
||||
|
||||
console.log("Workshop write rate-limit migration restoration passed");
|
||||
@@ -1,30 +0,0 @@
|
||||
RENAME TABLE `custom_widget_definition` TO `legacy_custom_widget_definition`, `custom_widget_secret` TO `legacy_custom_widget_secret`;--> statement-breakpoint
|
||||
ALTER TABLE `legacy_custom_widget_definition` DROP FOREIGN KEY `custom_widget_definition_creator_id_user_id_fk`;--> statement-breakpoint
|
||||
ALTER TABLE `legacy_custom_widget_definition` ADD CONSTRAINT `legacy_cw_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `legacy_custom_widget_secret` DROP FOREIGN KEY `cw_secret_definition_id_cw_definition_id_fk`;--> statement-breakpoint
|
||||
ALTER TABLE `legacy_custom_widget_secret` ADD CONSTRAINT `legacy_cw_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `legacy_custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE TABLE `custom_widget_definition` (
|
||||
`id` varchar(64) NOT NULL,
|
||||
`name` varchar(256) NOT NULL,
|
||||
`description` text,
|
||||
`icon_url` text,
|
||||
`sources` text NOT NULL,
|
||||
`requests` text NOT NULL,
|
||||
`options` text NOT NULL,
|
||||
`template` text NOT NULL,
|
||||
`enabled` boolean DEFAULT true NOT NULL,
|
||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
`updated_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
`creator_id` varchar(64),
|
||||
CONSTRAINT `custom_widget_definition_id` PRIMARY KEY(`id`)
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE `custom_widget_secret` (
|
||||
`source_id` varchar(64) NOT NULL,
|
||||
`kind` varchar(64) NOT NULL,
|
||||
`encrypted_value` text NOT NULL,
|
||||
`updated_at` timestamp NOT NULL,
|
||||
`definition_id` varchar(64) NOT NULL,
|
||||
CONSTRAINT `custom_widget_secret_definition_id_source_id_kind_pk` PRIMARY KEY(`definition_id`,`source_id`,`kind`)
|
||||
);--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_definition` ADD CONSTRAINT `custom_widget_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_secret` ADD CONSTRAINT `cw_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE `custom_widget_v2_definition` (
|
||||
`id` varchar(64) NOT NULL,
|
||||
`name` varchar(256) NOT NULL,
|
||||
`description` text,
|
||||
`icon_url` text,
|
||||
`sources` text NOT NULL,
|
||||
`requests` text NOT NULL,
|
||||
`options` text NOT NULL,
|
||||
`template` text NOT NULL,
|
||||
`enabled` boolean DEFAULT true NOT NULL,
|
||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
`updated_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
`creator_id` varchar(64),
|
||||
CONSTRAINT `custom_widget_v2_definition_id` PRIMARY KEY(`id`)
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE `custom_widget_v2_secret` (
|
||||
`source_id` varchar(64) NOT NULL,
|
||||
`kind` varchar(64) NOT NULL,
|
||||
`encrypted_value` text NOT NULL,
|
||||
`updated_at` timestamp NOT NULL,
|
||||
`definition_id` varchar(64) NOT NULL,
|
||||
CONSTRAINT `custom_widget_v2_secret_definition_id_source_id_kind_pk` PRIMARY KEY(`definition_id`,`source_id`,`kind`)
|
||||
);--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_v2_definition` ADD CONSTRAINT `custom_widget_v2_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_v2_secret` ADD CONSTRAINT `custom_widget_v2_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_v2_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
DELETE FROM `groupPermission` WHERE `permission` IN ('custom-widget-manage', 'custom-widget-secret-write');
|
||||
@@ -1,22 +0,0 @@
|
||||
RENAME TABLE
|
||||
`custom_widget_definition` TO `custom_widget_v2_definition`,
|
||||
`custom_widget_secret` TO `custom_widget_v2_secret`,
|
||||
`legacy_custom_widget_definition` TO `custom_widget_definition`,
|
||||
`legacy_custom_widget_secret` TO `custom_widget_secret`;--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_v2_definition`
|
||||
DROP FOREIGN KEY `custom_widget_definition_creator_id_user_id_fk`,
|
||||
ADD CONSTRAINT `custom_widget_v2_definition_creator_id_user_id_fk`
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_v2_secret`
|
||||
DROP FOREIGN KEY `cw_secret_definition_id_fk`,
|
||||
ADD CONSTRAINT `custom_widget_v2_secret_definition_id_fk`
|
||||
FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_v2_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_definition`
|
||||
DROP FOREIGN KEY `legacy_cw_definition_creator_id_user_id_fk`,
|
||||
ADD CONSTRAINT `custom_widget_definition_creator_id_user_id_fk`
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_secret`
|
||||
DROP FOREIGN KEY `legacy_cw_secret_definition_id_fk`,
|
||||
ADD CONSTRAINT `cw_secret_definition_id_cw_definition_id_fk`
|
||||
FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
DELETE FROM `groupPermission` WHERE `permission` IN ('custom-widget-manage', 'custom-widget-secret-write');
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "1a10d79c-6c82-4036-8717-0283ebaece10",
|
||||
"id": "e2f11bb0-59e9-4fab-8ae1-5bf764795c74",
|
||||
"prevId": "073d408e-ff80-4a57-8007-f1f32da1c71c",
|
||||
"tables": {
|
||||
"account": {
|
||||
@@ -534,198 +534,8 @@
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"legacy_custom_widget_definition": {
|
||||
"name": "legacy_custom_widget_definition",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auth_type": {
|
||||
"name": "auth_type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'none'"
|
||||
},
|
||||
"header_name": {
|
||||
"name": "header_name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"method": {
|
||||
"name": "method",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'GET'"
|
||||
},
|
||||
"request_body": {
|
||||
"name": "request_body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_type": {
|
||||
"name": "display_type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'singleValue'"
|
||||
},
|
||||
"display_config": {
|
||||
"name": "display_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "('{\"json\": {}}')"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"legacy_cw_definition_creator_id_user_id_fk": {
|
||||
"name": "legacy_cw_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "legacy_custom_widget_definition",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"legacy_custom_widget_definition_id": {
|
||||
"name": "legacy_custom_widget_definition_id",
|
||||
"columns": ["id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"legacy_custom_widget_secret": {
|
||||
"name": "legacy_custom_widget_secret",
|
||||
"columns": {
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"definition_id": {
|
||||
"name": "definition_id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"legacy_cw_secret_definition_id_fk": {
|
||||
"name": "legacy_cw_secret_definition_id_fk",
|
||||
"tableFrom": "legacy_custom_widget_secret",
|
||||
"tableTo": "legacy_custom_widget_definition",
|
||||
"columnsFrom": ["definition_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"legacy_custom_widget_secret_definition_id_kind_pk": {
|
||||
"name": "legacy_custom_widget_secret_definition_id_kind_pk",
|
||||
"columns": ["definition_id", "kind"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"custom_widget_definition": {
|
||||
"name": "custom_widget_definition",
|
||||
"custom_widget_v2_definition": {
|
||||
"name": "custom_widget_v2_definition",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
@@ -817,9 +627,9 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"custom_widget_definition_creator_id_user_id_fk": {
|
||||
"name": "custom_widget_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "custom_widget_definition",
|
||||
"custom_widget_v2_definition_creator_id_user_id_fk": {
|
||||
"name": "custom_widget_v2_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "custom_widget_v2_definition",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
@@ -828,16 +638,16 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"custom_widget_definition_id": {
|
||||
"name": "custom_widget_definition_id",
|
||||
"custom_widget_v2_definition_id": {
|
||||
"name": "custom_widget_v2_definition_id",
|
||||
"columns": ["id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"custom_widget_secret": {
|
||||
"name": "custom_widget_secret",
|
||||
"custom_widget_v2_secret": {
|
||||
"name": "custom_widget_v2_secret",
|
||||
"columns": {
|
||||
"source_id": {
|
||||
"name": "source_id",
|
||||
@@ -877,10 +687,10 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"cw_secret_definition_id_fk": {
|
||||
"name": "cw_secret_definition_id_fk",
|
||||
"tableFrom": "custom_widget_secret",
|
||||
"tableTo": "custom_widget_definition",
|
||||
"custom_widget_v2_secret_definition_id_fk": {
|
||||
"name": "custom_widget_v2_secret_definition_id_fk",
|
||||
"tableFrom": "custom_widget_v2_secret",
|
||||
"tableTo": "custom_widget_v2_definition",
|
||||
"columnsFrom": ["definition_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
@@ -888,8 +698,8 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"custom_widget_secret_definition_id_source_id_kind_pk": {
|
||||
"name": "custom_widget_secret_definition_id_source_id_kind_pk",
|
||||
"custom_widget_v2_secret_definition_id_source_id_kind_pk": {
|
||||
"name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
|
||||
"columns": ["definition_id", "source_id", "kind"]
|
||||
}
|
||||
},
|
||||
@@ -1718,6 +1528,196 @@
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"custom_widget_definition": {
|
||||
"name": "custom_widget_definition",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auth_type": {
|
||||
"name": "auth_type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'none'"
|
||||
},
|
||||
"header_name": {
|
||||
"name": "header_name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"method": {
|
||||
"name": "method",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'GET'"
|
||||
},
|
||||
"request_body": {
|
||||
"name": "request_body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_type": {
|
||||
"name": "display_type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'singleValue'"
|
||||
},
|
||||
"display_config": {
|
||||
"name": "display_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "('{\"json\": {}}')"
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"custom_widget_definition_creator_id_user_id_fk": {
|
||||
"name": "custom_widget_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "custom_widget_definition",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"custom_widget_definition_id": {
|
||||
"name": "custom_widget_definition_id",
|
||||
"columns": ["id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"custom_widget_secret": {
|
||||
"name": "custom_widget_secret",
|
||||
"columns": {
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"definition_id": {
|
||||
"name": "definition_id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"cw_secret_definition_id_cw_definition_id_fk": {
|
||||
"name": "cw_secret_definition_id_cw_definition_id_fk",
|
||||
"tableFrom": "custom_widget_secret",
|
||||
"tableTo": "custom_widget_definition",
|
||||
"columnsFrom": ["definition_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"custom_widget_secret_definition_id_kind_pk": {
|
||||
"name": "custom_widget_secret_definition_id_kind_pk",
|
||||
"columns": ["definition_id", "kind"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"media": {
|
||||
"name": "media",
|
||||
"columns": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -314,14 +314,7 @@
|
||||
"idx": 44,
|
||||
"version": "5",
|
||||
"when": 1784305537309,
|
||||
"tag": "0044_custom_widget_v2_reset",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "5",
|
||||
"when": 1785225865332,
|
||||
"tag": "0045_custom_widget_v1_compatibility",
|
||||
"tag": "0044_custom_widget_v2_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
ALTER TABLE "custom_widget_definition" RENAME TO "legacy_custom_widget_definition";--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_secret" RENAME TO "legacy_custom_widget_secret";--> statement-breakpoint
|
||||
ALTER TABLE "legacy_custom_widget_definition" RENAME CONSTRAINT "custom_widget_definition_creator_id_user_id_fk" TO "legacy_cw_definition_creator_id_user_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "legacy_custom_widget_secret" RENAME CONSTRAINT "custom_widget_secret_definition_id_custom_widget_definition_id_fk" TO "legacy_cw_secret_definition_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "legacy_custom_widget_secret" RENAME CONSTRAINT "custom_widget_secret_definition_id_kind_pk" TO "legacy_custom_widget_secret_definition_id_kind_pk";--> statement-breakpoint
|
||||
CREATE TABLE "custom_widget_definition" (
|
||||
"id" varchar(64) PRIMARY KEY NOT NULL,
|
||||
"name" varchar(256) NOT NULL,
|
||||
"description" text,
|
||||
"icon_url" text,
|
||||
"sources" text NOT NULL,
|
||||
"requests" text NOT NULL,
|
||||
"options" text NOT NULL,
|
||||
"template" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"creator_id" varchar(64)
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE "custom_widget_secret" (
|
||||
"source_id" varchar(64) NOT NULL,
|
||||
"kind" varchar(64) NOT NULL,
|
||||
"encrypted_value" text NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"definition_id" varchar(64) NOT NULL,
|
||||
CONSTRAINT "custom_widget_secret_definition_id_source_id_kind_pk" PRIMARY KEY("definition_id","source_id","kind")
|
||||
);--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_definition" ADD CONSTRAINT "custom_widget_definition_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_secret" ADD CONSTRAINT "cw_secret_definition_id_fk" FOREIGN KEY ("definition_id") REFERENCES "public"."custom_widget_definition"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "custom_widget_v2_definition" (
|
||||
"id" varchar(64) PRIMARY KEY NOT NULL,
|
||||
"name" varchar(256) NOT NULL,
|
||||
"description" text,
|
||||
"icon_url" text,
|
||||
"sources" text NOT NULL,
|
||||
"requests" text NOT NULL,
|
||||
"options" text NOT NULL,
|
||||
"template" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"creator_id" varchar(64)
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE "custom_widget_v2_secret" (
|
||||
"source_id" varchar(64) NOT NULL,
|
||||
"kind" varchar(64) NOT NULL,
|
||||
"encrypted_value" text NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"definition_id" varchar(64) NOT NULL,
|
||||
CONSTRAINT "custom_widget_v2_secret_definition_id_source_id_kind_pk" PRIMARY KEY("definition_id","source_id","kind")
|
||||
);--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_v2_definition" ADD CONSTRAINT "custom_widget_v2_definition_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_v2_secret" ADD CONSTRAINT "custom_widget_v2_secret_definition_id_fk" FOREIGN KEY ("definition_id") REFERENCES "public"."custom_widget_v2_definition"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
DELETE FROM "groupPermission" WHERE "permission" IN ('custom-widget-manage', 'custom-widget-secret-write');
|
||||
@@ -1,23 +0,0 @@
|
||||
ALTER TABLE "custom_widget_definition" RENAME TO "custom_widget_v2_definition";--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_secret" RENAME TO "custom_widget_v2_secret";--> statement-breakpoint
|
||||
ALTER TABLE "legacy_custom_widget_definition" RENAME TO "custom_widget_definition";--> statement-breakpoint
|
||||
ALTER TABLE "legacy_custom_widget_secret" RENAME TO "custom_widget_secret";--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_v2_definition"
|
||||
RENAME CONSTRAINT "custom_widget_definition_creator_id_user_id_fk"
|
||||
TO "custom_widget_v2_definition_creator_id_user_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_v2_secret"
|
||||
RENAME CONSTRAINT "cw_secret_definition_id_fk"
|
||||
TO "custom_widget_v2_secret_definition_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_v2_secret"
|
||||
RENAME CONSTRAINT "custom_widget_secret_definition_id_source_id_kind_pk"
|
||||
TO "custom_widget_v2_secret_definition_id_source_id_kind_pk";--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_definition"
|
||||
RENAME CONSTRAINT "legacy_cw_definition_creator_id_user_id_fk"
|
||||
TO "custom_widget_definition_creator_id_user_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_secret"
|
||||
RENAME CONSTRAINT "legacy_cw_secret_definition_id_fk"
|
||||
TO "custom_widget_secret_definition_id_custom_widget_definition_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "custom_widget_secret"
|
||||
RENAME CONSTRAINT "legacy_custom_widget_secret_definition_id_kind_pk"
|
||||
TO "custom_widget_secret_definition_id_kind_pk";--> statement-breakpoint
|
||||
DELETE FROM "groupPermission" WHERE "permission" IN ('custom-widget-manage', 'custom-widget-secret-write');
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "f2cd8146-b478-4617-870e-f601fb437d15",
|
||||
"id": "99938d86-5059-41b3-a21c-ac767440ed00",
|
||||
"prevId": "97bda61f-0a71-4e41-8e60-874aafe17115",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -498,180 +498,8 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.legacy_custom_widget_definition": {
|
||||
"name": "legacy_custom_widget_definition",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"auth_type": {
|
||||
"name": "auth_type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'none'"
|
||||
},
|
||||
"header_name": {
|
||||
"name": "header_name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"method": {
|
||||
"name": "method",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'GET'"
|
||||
},
|
||||
"request_body": {
|
||||
"name": "request_body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"display_type": {
|
||||
"name": "display_type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'singleValue'"
|
||||
},
|
||||
"display_config": {
|
||||
"name": "display_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{\"json\": {}}'"
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"legacy_cw_definition_creator_id_user_id_fk": {
|
||||
"name": "legacy_cw_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "legacy_custom_widget_definition",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.legacy_custom_widget_secret": {
|
||||
"name": "legacy_custom_widget_secret",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"definition_id": {
|
||||
"name": "definition_id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"legacy_cw_secret_definition_id_fk": {
|
||||
"name": "legacy_cw_secret_definition_id_fk",
|
||||
"tableFrom": "legacy_custom_widget_secret",
|
||||
"tableTo": "legacy_custom_widget_definition",
|
||||
"columnsFrom": ["definition_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"legacy_custom_widget_secret_definition_id_kind_pk": {
|
||||
"name": "legacy_custom_widget_secret_definition_id_kind_pk",
|
||||
"columns": ["definition_id", "kind"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.custom_widget_definition": {
|
||||
"name": "custom_widget_definition",
|
||||
"public.custom_widget_v2_definition": {
|
||||
"name": "custom_widget_v2_definition",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
@@ -752,9 +580,9 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"custom_widget_definition_creator_id_user_id_fk": {
|
||||
"name": "custom_widget_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "custom_widget_definition",
|
||||
"custom_widget_v2_definition_creator_id_user_id_fk": {
|
||||
"name": "custom_widget_v2_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "custom_widget_v2_definition",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
@@ -768,8 +596,8 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.custom_widget_secret": {
|
||||
"name": "custom_widget_secret",
|
||||
"public.custom_widget_v2_secret": {
|
||||
"name": "custom_widget_v2_secret",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"source_id": {
|
||||
@@ -805,10 +633,10 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"cw_secret_definition_id_fk": {
|
||||
"name": "cw_secret_definition_id_fk",
|
||||
"tableFrom": "custom_widget_secret",
|
||||
"tableTo": "custom_widget_definition",
|
||||
"custom_widget_v2_secret_definition_id_fk": {
|
||||
"name": "custom_widget_v2_secret_definition_id_fk",
|
||||
"tableFrom": "custom_widget_v2_secret",
|
||||
"tableTo": "custom_widget_v2_definition",
|
||||
"columnsFrom": ["definition_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
@@ -816,8 +644,8 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"custom_widget_secret_definition_id_source_id_kind_pk": {
|
||||
"name": "custom_widget_secret_definition_id_source_id_kind_pk",
|
||||
"custom_widget_v2_secret_definition_id_source_id_kind_pk": {
|
||||
"name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
|
||||
"columns": ["definition_id", "source_id", "kind"]
|
||||
}
|
||||
},
|
||||
@@ -1632,6 +1460,178 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.custom_widget_definition": {
|
||||
"name": "custom_widget_definition",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"auth_type": {
|
||||
"name": "auth_type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'none'"
|
||||
},
|
||||
"header_name": {
|
||||
"name": "header_name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"method": {
|
||||
"name": "method",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'GET'"
|
||||
},
|
||||
"request_body": {
|
||||
"name": "request_body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"display_type": {
|
||||
"name": "display_type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'singleValue'"
|
||||
},
|
||||
"display_config": {
|
||||
"name": "display_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{\"json\": {}}'"
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"custom_widget_definition_creator_id_user_id_fk": {
|
||||
"name": "custom_widget_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "custom_widget_definition",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.custom_widget_secret": {
|
||||
"name": "custom_widget_secret",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"definition_id": {
|
||||
"name": "definition_id",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
|
||||
"name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
|
||||
"tableFrom": "custom_widget_secret",
|
||||
"tableTo": "custom_widget_definition",
|
||||
"columnsFrom": ["definition_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"custom_widget_secret_definition_id_kind_pk": {
|
||||
"name": "custom_widget_secret_definition_id_kind_pk",
|
||||
"columns": ["definition_id", "kind"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.media": {
|
||||
"name": "media",
|
||||
"schema": "",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -76,14 +76,7 @@
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1784305412862,
|
||||
"tag": "0010_custom_widget_v2_reset",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1785225823345,
|
||||
"tag": "0011_custom_widget_v1_compatibility",
|
||||
"tag": "0010_custom_widget_v2_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
+4
-7
@@ -1,7 +1,4 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_definition` RENAME TO `legacy_custom_widget_definition`;--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_secret` RENAME TO `legacy_custom_widget_secret`;--> statement-breakpoint
|
||||
CREATE TABLE `custom_widget_definition` (
|
||||
CREATE TABLE `custom_widget_v2_definition` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
@@ -16,13 +13,13 @@ CREATE TABLE `custom_widget_definition` (
|
||||
`creator_id` text,
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE `custom_widget_secret` (
|
||||
CREATE TABLE `custom_widget_v2_secret` (
|
||||
`source_id` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`encrypted_value` text NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`definition_id` text NOT NULL,
|
||||
PRIMARY KEY(`definition_id`, `source_id`, `kind`),
|
||||
FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_v2_definition`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
DELETE FROM `groupPermission` WHERE `permission` IN ('custom-widget-manage', 'custom-widget-secret-write');
|
||||
@@ -1,5 +0,0 @@
|
||||
ALTER TABLE `custom_widget_secret` RENAME TO `custom_widget_v2_secret`;--> statement-breakpoint
|
||||
ALTER TABLE `custom_widget_definition` RENAME TO `custom_widget_v2_definition`;--> statement-breakpoint
|
||||
ALTER TABLE `legacy_custom_widget_secret` RENAME TO `custom_widget_secret`;--> statement-breakpoint
|
||||
ALTER TABLE `legacy_custom_widget_definition` RENAME TO `custom_widget_definition`;--> statement-breakpoint
|
||||
DELETE FROM `groupPermission` WHERE `permission` IN ('custom-widget-manage', 'custom-widget-secret-write');
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "30bbf6f8-3a3c-4cb0-994e-9d8494e4fcd0",
|
||||
"id": "51923913-fdc0-4ae7-9127-e86587e0b51f",
|
||||
"prevId": "852e049b-72cc-4d7f-8be9-931c042fe019",
|
||||
"tables": {
|
||||
"account": {
|
||||
@@ -515,193 +515,8 @@
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"legacy_custom_widget_definition": {
|
||||
"name": "legacy_custom_widget_definition",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auth_type": {
|
||||
"name": "auth_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'none'"
|
||||
},
|
||||
"header_name": {
|
||||
"name": "header_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"method": {
|
||||
"name": "method",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'GET'"
|
||||
},
|
||||
"request_body": {
|
||||
"name": "request_body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_type": {
|
||||
"name": "display_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'singleValue'"
|
||||
},
|
||||
"display_config": {
|
||||
"name": "display_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'{\"json\": {}}'"
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"legacy_cw_definition_creator_id_user_id_fk": {
|
||||
"name": "legacy_cw_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "legacy_custom_widget_definition",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"legacy_custom_widget_secret": {
|
||||
"name": "legacy_custom_widget_secret",
|
||||
"columns": {
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"definition_id": {
|
||||
"name": "definition_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"legacy_cw_secret_definition_id_fk": {
|
||||
"name": "legacy_cw_secret_definition_id_fk",
|
||||
"tableFrom": "legacy_custom_widget_secret",
|
||||
"tableTo": "legacy_custom_widget_definition",
|
||||
"columnsFrom": ["definition_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"legacy_custom_widget_secret_definition_id_kind_pk": {
|
||||
"columns": ["definition_id", "kind"],
|
||||
"name": "legacy_custom_widget_secret_definition_id_kind_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"custom_widget_definition": {
|
||||
"name": "custom_widget_definition",
|
||||
"custom_widget_v2_definition": {
|
||||
"name": "custom_widget_v2_definition",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
@@ -793,9 +608,9 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"custom_widget_definition_creator_id_user_id_fk": {
|
||||
"name": "custom_widget_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "custom_widget_definition",
|
||||
"custom_widget_v2_definition_creator_id_user_id_fk": {
|
||||
"name": "custom_widget_v2_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "custom_widget_v2_definition",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
@@ -807,8 +622,8 @@
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"custom_widget_secret": {
|
||||
"name": "custom_widget_secret",
|
||||
"custom_widget_v2_secret": {
|
||||
"name": "custom_widget_v2_secret",
|
||||
"columns": {
|
||||
"source_id": {
|
||||
"name": "source_id",
|
||||
@@ -848,10 +663,10 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"cw_secret_definition_id_fk": {
|
||||
"name": "cw_secret_definition_id_fk",
|
||||
"tableFrom": "custom_widget_secret",
|
||||
"tableTo": "custom_widget_definition",
|
||||
"custom_widget_v2_secret_definition_id_fk": {
|
||||
"name": "custom_widget_v2_secret_definition_id_fk",
|
||||
"tableFrom": "custom_widget_v2_secret",
|
||||
"tableTo": "custom_widget_v2_definition",
|
||||
"columnsFrom": ["definition_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
@@ -859,9 +674,9 @@
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"custom_widget_secret_definition_id_source_id_kind_pk": {
|
||||
"custom_widget_v2_secret_definition_id_source_id_kind_pk": {
|
||||
"columns": ["definition_id", "source_id", "kind"],
|
||||
"name": "custom_widget_secret_definition_id_source_id_kind_pk"
|
||||
"name": "custom_widget_v2_secret_definition_id_source_id_kind_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
@@ -1656,6 +1471,191 @@
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"custom_widget_definition": {
|
||||
"name": "custom_widget_definition",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auth_type": {
|
||||
"name": "auth_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'none'"
|
||||
},
|
||||
"header_name": {
|
||||
"name": "header_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"method": {
|
||||
"name": "method",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'GET'"
|
||||
},
|
||||
"request_body": {
|
||||
"name": "request_body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_type": {
|
||||
"name": "display_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'singleValue'"
|
||||
},
|
||||
"display_config": {
|
||||
"name": "display_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'{\"json\": {}}'"
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"custom_widget_definition_creator_id_user_id_fk": {
|
||||
"name": "custom_widget_definition_creator_id_user_id_fk",
|
||||
"tableFrom": "custom_widget_definition",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"custom_widget_secret": {
|
||||
"name": "custom_widget_secret",
|
||||
"columns": {
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"definition_id": {
|
||||
"name": "definition_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
|
||||
"name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
|
||||
"tableFrom": "custom_widget_secret",
|
||||
"tableTo": "custom_widget_definition",
|
||||
"columnsFrom": ["definition_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"custom_widget_secret_definition_id_kind_pk": {
|
||||
"columns": ["definition_id", "kind"],
|
||||
"name": "custom_widget_secret_definition_id_kind_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"media": {
|
||||
"name": "media",
|
||||
"columns": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -300,14 +300,7 @@
|
||||
"idx": 42,
|
||||
"version": "6",
|
||||
"when": 1784305283910,
|
||||
"tag": "0042_custom_widget_v2_reset",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "6",
|
||||
"when": 1785225790467,
|
||||
"tag": "0043_custom_widget_v1_compatibility",
|
||||
"tag": "0042_custom_widget_v2_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("Mysql Migration", () => {
|
||||
await mysqlContainer.stop();
|
||||
}, 90_000);
|
||||
|
||||
test("preserves v1 and already-created v2 custom widgets, secrets, and board references", async () => {
|
||||
test("Custom Widget v2 migration preserves v1 data and board references", async () => {
|
||||
const mysqlContainer = await new MySqlContainer("mysql:latest").start();
|
||||
const connection = mysql.createConnection({
|
||||
host: mysqlContainer.getHost(),
|
||||
@@ -132,20 +132,19 @@ describe("Mysql Migration", () => {
|
||||
legacyPlacementOptions,
|
||||
]);
|
||||
|
||||
await applyMigration(connection, "0044_custom_widget_v2_reset.sql");
|
||||
await applyMigration(connection, "0044_custom_widget_v2_tables.sql");
|
||||
await sql.query(`
|
||||
INSERT INTO \`custom_widget_definition\` (
|
||||
INSERT INTO \`custom_widget_v2_definition\` (
|
||||
\`id\`, \`name\`, \`sources\`, \`requests\`, \`options\`, \`template\`, \`enabled\`,
|
||||
\`created_at\`, \`updated_at\`, \`creator_id\`
|
||||
) VALUES (
|
||||
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
|
||||
'2023-11-14 00:00:02', '2023-11-14 00:00:03', 'owner'
|
||||
);
|
||||
INSERT INTO \`custom_widget_secret\` (
|
||||
INSERT INTO \`custom_widget_v2_secret\` (
|
||||
\`source_id\`, \`kind\`, \`encrypted_value\`, \`updated_at\`, \`definition_id\`
|
||||
) VALUES ('weather', 'apiKey', 'encrypted.v2-value', '2023-11-14 00:00:03', 'legacy-weather');
|
||||
`);
|
||||
await applyMigration(connection, "0045_custom_widget_v1_compatibility.sql");
|
||||
|
||||
const [definitions] = await sql.query<mysql.RowDataPacket[]>(
|
||||
"SELECT id, name, url, enabled, creator_id FROM custom_widget_definition",
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("PostgreSql Migration", () => {
|
||||
await postgreSqlContainer.stop();
|
||||
}, 40_000);
|
||||
|
||||
test("preserves v1 and already-created v2 custom widgets, secrets, and board references", async () => {
|
||||
test("Custom Widget v2 migration preserves v1 data and board references", async () => {
|
||||
const postgreSqlContainer = await new PostgreSqlContainer("postgres:latest").start();
|
||||
const pool = new Pool({
|
||||
user: postgreSqlContainer.getUsername(),
|
||||
@@ -140,20 +140,19 @@ describe("PostgreSql Migration", () => {
|
||||
legacyPlacementOptions,
|
||||
]);
|
||||
|
||||
await applyMigration(pool, "0010_custom_widget_v2_reset.sql");
|
||||
await applyMigration(pool, "0010_custom_widget_v2_tables.sql");
|
||||
await pool.query(`
|
||||
INSERT INTO "custom_widget_definition" (
|
||||
INSERT INTO "custom_widget_v2_definition" (
|
||||
"id", "name", "sources", "requests", "options", "template", "enabled",
|
||||
"created_at", "updated_at", "creator_id"
|
||||
) VALUES (
|
||||
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
|
||||
'2023-11-14 00:00:02', '2023-11-14 00:00:03', 'owner'
|
||||
);
|
||||
INSERT INTO "custom_widget_secret" (
|
||||
INSERT INTO "custom_widget_v2_secret" (
|
||||
"source_id", "kind", "encrypted_value", "updated_at", "definition_id"
|
||||
) VALUES ('weather', 'apiKey', 'encrypted.v2-value', '2023-11-14 00:00:03', 'legacy-weather');
|
||||
`);
|
||||
await applyMigration(pool, "0011_custom_widget_v1_compatibility.sql");
|
||||
|
||||
expect(
|
||||
(await pool.query("SELECT id, name, url, enabled, creator_id FROM custom_widget_definition")).rows,
|
||||
|
||||
@@ -45,7 +45,7 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("Custom JSX compatibility migration preserves v1 and already-created v2 data", () => {
|
||||
test("Custom Widget v2 migration preserves v1 data and board references", () => {
|
||||
const connection = new BetterSqlite3(":memory:");
|
||||
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
|
||||
connection.exec(`
|
||||
@@ -99,18 +99,17 @@ test("Custom JSX compatibility migration preserves v1 and already-created v2 dat
|
||||
.prepare("INSERT INTO item (id, kind, options) VALUES ('weather-item', 'customApi', ?)")
|
||||
.run(legacyPlacementOptions);
|
||||
|
||||
applyMigration(connection, "0042_custom_widget_v2_reset.sql");
|
||||
applyMigration(connection, "0042_custom_widget_v2_tables.sql");
|
||||
connection.exec(`
|
||||
INSERT INTO custom_widget_definition (
|
||||
INSERT INTO custom_widget_v2_definition (
|
||||
id, name, sources, requests, options, template, enabled, created_at, updated_at, creator_id
|
||||
) VALUES (
|
||||
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
|
||||
1700000002, 1700000003, 'owner'
|
||||
);
|
||||
INSERT INTO custom_widget_secret (source_id, kind, encrypted_value, updated_at, definition_id)
|
||||
INSERT INTO custom_widget_v2_secret (source_id, kind, encrypted_value, updated_at, definition_id)
|
||||
VALUES ('weather', 'apiKey', 'encrypted.v2-value', 1700000003, 'legacy-weather');
|
||||
`);
|
||||
applyMigration(connection, "0043_custom_widget_v1_compatibility.sql");
|
||||
|
||||
expect(connection.prepare("SELECT * FROM custom_widget_definition").get()).toMatchObject({
|
||||
id: "legacy-weather",
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"pocketbase": "catalog:",
|
||||
"react": "catalog:",
|
||||
"tr46": "6.0.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CUSTOM_WIDGET_STARTER, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { PocketBaseUrl } from "./pocketbase-url";
|
||||
|
||||
describe("PocketBase URL compatibility", () => {
|
||||
test.each([
|
||||
"https://example.com",
|
||||
"https://example.com.",
|
||||
"https://münich.example",
|
||||
"https://xn--mnich-kva.example",
|
||||
"http://example.com:0",
|
||||
"http://example.com:",
|
||||
"http://example.com:80",
|
||||
"http://[::1]:8090",
|
||||
"http://[::ffff:192.168.1.1]",
|
||||
])("accepts canonical URL %s", (value) => {
|
||||
expect(URL.canParse(value)).toBe(true);
|
||||
expect(PocketBaseUrl.canParse(value)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
"http://",
|
||||
"https://:",
|
||||
"http://1.2.3.4.5",
|
||||
"http://09.0.0.1",
|
||||
"http://099.0.0.1",
|
||||
"http://[:::1]",
|
||||
"http://[:1:2:3:4:5:6:7:8]",
|
||||
"http://[1:2:3:4:5:6:7:8:]",
|
||||
"http://[192.168.1.1::]",
|
||||
"http://[::ffff:192.168.001.001]",
|
||||
"http://[::ffff:192.168.1.01]",
|
||||
"http://[fe80::1%25eth0]",
|
||||
"http://example.com:65536",
|
||||
"https://xn--a.example",
|
||||
"https://xn--0.example",
|
||||
"https://xn--abc.example",
|
||||
])("rejects canonical invalid URL %s", (value) => {
|
||||
expect(URL.canParse(value)).toBe(false);
|
||||
expect(PocketBaseUrl.canParse(value)).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
"http://127.1",
|
||||
"http://2130706433",
|
||||
"http://0x7f000001",
|
||||
"http://0177.0.0.1",
|
||||
"http://1.2.3",
|
||||
"http://%31%32%37.0.0.1",
|
||||
"https://%65xample.com",
|
||||
"https://example%2ecom",
|
||||
"https://example.com\\@attacker.invalid",
|
||||
"https:\\\\example.com\\api",
|
||||
"https://-example.com",
|
||||
"https://example-.com",
|
||||
"https://example..com",
|
||||
"https://example.com:00080",
|
||||
"https://example.com:0000000065535",
|
||||
"https://example。com",
|
||||
"https://127.0.0.1",
|
||||
" https://example.com",
|
||||
"https://exa\tmple.com",
|
||||
"https://example.com/path\u00a0",
|
||||
])("conservatively rejects WHATWG-normalized URL %s", (value) => {
|
||||
expect(URL.canParse(value)).toBe(true);
|
||||
expect(PocketBaseUrl.canParse(value)).toBe(false);
|
||||
});
|
||||
|
||||
test("normalizes international hostnames like the native parser", () => {
|
||||
expect(new PocketBaseUrl("https://münich.example").hostname).toBe("xn--mnich-kva.example");
|
||||
expect(new PocketBaseUrl("https://例え.テスト.").hostname).toBe("xn--r8jz45g.xn--zckzah.");
|
||||
});
|
||||
|
||||
test("normalizes IPv6 hostnames like the native parser", () => {
|
||||
expect(new PocketBaseUrl("http://[1:2:3:4:5:6:7::]").hostname).toBe("[1:2:3:4:5:6:7:0]");
|
||||
expect(new PocketBaseUrl("http://[::ffff:192.168.1.1]").hostname).toBe("[::ffff:c0a8:101]");
|
||||
});
|
||||
|
||||
test.each([
|
||||
"https://example.com",
|
||||
"https://example.com.",
|
||||
"https://münich.example",
|
||||
"https://例え.テスト",
|
||||
"https://xn--r8jz45g.xn--zckzah",
|
||||
"http://192.168.1.1:0",
|
||||
"https://[2001:db8::1]:65535",
|
||||
"https://[::ffff:192.168.1.1]",
|
||||
"http://0.0.0.0",
|
||||
"http://255.255.255.255",
|
||||
"https://example.com:",
|
||||
"https://example.com:0",
|
||||
"https://example.com:65535",
|
||||
"http://[::]",
|
||||
"http://[::1]",
|
||||
"http://[1:2:3:4:5:6:7:8]",
|
||||
"http://[1:2:3:4:5:6:7::]",
|
||||
"https://faß.de",
|
||||
"https://xn--fa-hia.de",
|
||||
"https://مثال.إختبار",
|
||||
"https://a_b.example",
|
||||
"https://example.com/%5c@evil",
|
||||
"https://example.com/%2f%2fevil",
|
||||
"https://example.com/icon.svg?next=https%3A%2F%2Fevil#preview",
|
||||
"http://127.1",
|
||||
"http://2130706433",
|
||||
"http://0x7f000001",
|
||||
"http://0xffffffff",
|
||||
"http://0x100000000",
|
||||
"http://0177.0.0.1",
|
||||
"http://0377.0377.0377.0377",
|
||||
"http://1.2.3",
|
||||
"http://1.",
|
||||
"http://1..2",
|
||||
"http://256.0.0.1",
|
||||
"http://00.0.0.0",
|
||||
"http://%31%32%37.0.0.1",
|
||||
"https://%65xample.com",
|
||||
"https://example%2ecom",
|
||||
"https://example.com\\@attacker.invalid",
|
||||
"https:\\\\example.com\\api",
|
||||
" https://example.com",
|
||||
"https://exa\tmple.com",
|
||||
"https://example.com\n",
|
||||
"https://example.com\u00a0",
|
||||
"https://@example.com",
|
||||
"https://:@example.com",
|
||||
"https://example。com",
|
||||
"https://127.0.0.1",
|
||||
"https://-example.com",
|
||||
"https://example-.com",
|
||||
"https://example..com",
|
||||
"https://example.com:01",
|
||||
"https://example.com:00080",
|
||||
"https://example.com:0000000065535",
|
||||
"https://example.com:65536",
|
||||
"https://example.com:655350",
|
||||
"https://[fe80::1%25eth0]",
|
||||
"https://[::ffff:192.168.001.001]",
|
||||
"https://[1:2:3:4:5:6:7:8:9]",
|
||||
"https://\u200d.example",
|
||||
"http:example.com",
|
||||
"https:/example.com",
|
||||
"https:///example.com",
|
||||
"ftp://example.com",
|
||||
"file:///tmp/icon.svg",
|
||||
"https://user:password@example.com",
|
||||
])("keeps canonical source and icon validation in parity for %s", (value) => {
|
||||
const validate = (UrlImplementation: typeof URL, field: "source" | "icon") => {
|
||||
const runtime = globalThis as unknown as { URL: typeof URL };
|
||||
const originalUrl = runtime.URL;
|
||||
runtime.URL = UrlImplementation;
|
||||
try {
|
||||
const definition =
|
||||
field === "source"
|
||||
? {
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
sources: {
|
||||
default: { ...CUSTOM_WIDGET_STARTER.sources.default, baseUrl: value },
|
||||
},
|
||||
}
|
||||
: { ...CUSTOM_WIDGET_STARTER, iconUrl: value };
|
||||
return customWidgetDefinitionSchema.safeParse(definition).success;
|
||||
} finally {
|
||||
runtime.URL = originalUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const nativeUrl = URL;
|
||||
expect(validate(PocketBaseUrl as unknown as typeof URL, "source")).toBe(validate(nativeUrl, "source"));
|
||||
expect(validate(PocketBaseUrl as unknown as typeof URL, "icon")).toBe(validate(nativeUrl, "icon"));
|
||||
});
|
||||
|
||||
test("exposes the URL fields used by the canonical schema", () => {
|
||||
const url = new PocketBaseUrl("https://user:password@example.com:8443/icon.svg?token=secret&mode=dark#icon");
|
||||
expect(url).toMatchObject({
|
||||
protocol: "https:",
|
||||
username: "user",
|
||||
password: "password",
|
||||
hostname: "example.com",
|
||||
port: "8443",
|
||||
search: "?token=secret&mode=dark",
|
||||
hash: "#icon",
|
||||
});
|
||||
expect(url.searchParams.keys()).toEqual(["token", "mode"]);
|
||||
});
|
||||
|
||||
test("validates credential queries without iterable URLSearchParams", () => {
|
||||
const runtime = globalThis as unknown as { URL: typeof URL };
|
||||
const originalUrl = runtime.URL;
|
||||
runtime.URL = PocketBaseUrl as unknown as typeof URL;
|
||||
try {
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
iconUrl: "https://example.com/icon.svg?clientSecret=must-never-be-published",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
requests: {
|
||||
default: {
|
||||
...CUSTOM_WIDGET_STARTER.requests.default,
|
||||
path: "/api/status?key=status&auth=none",
|
||||
},
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
} finally {
|
||||
runtime.URL = originalUrl;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
import { toASCII } from "tr46";
|
||||
|
||||
interface PortableSearchParams {
|
||||
keys(): string[];
|
||||
}
|
||||
|
||||
interface ParsedAuthority {
|
||||
username: string;
|
||||
password: string;
|
||||
hostname: string;
|
||||
port: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PocketBase's Goja runtime intentionally has no browser URL global. The
|
||||
* canonical Custom Widget schema uses the small subset implemented here.
|
||||
*/
|
||||
export class PocketBaseUrl {
|
||||
public readonly protocol: string;
|
||||
public readonly username: string;
|
||||
public readonly password: string;
|
||||
public readonly hostname: string;
|
||||
public readonly port: string;
|
||||
public readonly search: string;
|
||||
public readonly hash: string;
|
||||
public readonly origin: string;
|
||||
public readonly searchParams: PortableSearchParams;
|
||||
|
||||
public static canParse(value: unknown): boolean {
|
||||
try {
|
||||
const parsed = new PocketBaseUrl(value);
|
||||
void parsed;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public constructor(value: unknown) {
|
||||
if (typeof value !== "string" || hasForbiddenUrlCharacter(value) || value.includes("\\")) {
|
||||
throw new TypeError("Invalid URL");
|
||||
}
|
||||
|
||||
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/u);
|
||||
if (!scheme?.[1]) throw new TypeError("Invalid URL");
|
||||
this.protocol = `${scheme[1].toLowerCase()}:`;
|
||||
|
||||
const afterScheme = value.slice(scheme[0].length);
|
||||
const hashIndex = afterScheme.indexOf("#");
|
||||
const withoutHash = hashIndex < 0 ? afterScheme : afterScheme.slice(0, hashIndex);
|
||||
this.hash = hashIndex < 0 ? "" : afterScheme.slice(hashIndex);
|
||||
const searchIndex = withoutHash.indexOf("?");
|
||||
const withoutSearch = searchIndex < 0 ? withoutHash : withoutHash.slice(0, searchIndex);
|
||||
this.search = searchIndex < 0 ? "" : withoutHash.slice(searchIndex);
|
||||
this.searchParams = createSearchParams(this.search);
|
||||
|
||||
const requiresAuthority = this.protocol === "http:" || this.protocol === "https:" || this.protocol === "ftp:";
|
||||
if (!withoutSearch.startsWith("//")) {
|
||||
if (requiresAuthority) throw new TypeError("Invalid URL");
|
||||
this.username = "";
|
||||
this.password = "";
|
||||
this.hostname = "";
|
||||
this.port = "";
|
||||
this.origin = "null";
|
||||
return;
|
||||
}
|
||||
|
||||
const authorityAndPath = withoutSearch.slice(2);
|
||||
const slashIndex = authorityAndPath.indexOf("/");
|
||||
const authority = slashIndex < 0 ? authorityAndPath : authorityAndPath.slice(0, slashIndex);
|
||||
const parsed = parseAuthority(authority);
|
||||
this.username = parsed.username;
|
||||
this.password = parsed.password;
|
||||
this.hostname = parsed.hostname;
|
||||
this.port = parsed.port;
|
||||
this.origin = `${this.protocol}//${this.hostname}${this.port ? `:${this.port}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function installPocketBaseUrlPolyfill(): void {
|
||||
const runtime = globalThis as unknown as Record<string, unknown>;
|
||||
if (typeof runtime.URL === "undefined") runtime.URL = PocketBaseUrl;
|
||||
}
|
||||
|
||||
function parseAuthority(authority: string): ParsedAuthority {
|
||||
if (!authority || /[。.。]/u.test(authority)) throw new TypeError("Invalid URL");
|
||||
const atIndex = authority.lastIndexOf("@");
|
||||
const credentials = atIndex < 0 ? "" : authority.slice(0, atIndex);
|
||||
const hostAndPort = atIndex < 0 ? authority : authority.slice(atIndex + 1);
|
||||
if (!hostAndPort) throw new TypeError("Invalid URL");
|
||||
|
||||
const credentialSeparator = credentials.indexOf(":");
|
||||
const username = credentialSeparator < 0 ? credentials : credentials.slice(0, credentialSeparator);
|
||||
const password = credentialSeparator < 0 ? "" : credentials.slice(credentialSeparator + 1);
|
||||
|
||||
let hostname: string;
|
||||
let port = "";
|
||||
if (hostAndPort.startsWith("[")) {
|
||||
const closeIndex = hostAndPort.indexOf("]");
|
||||
if (closeIndex < 0) throw new TypeError("Invalid URL");
|
||||
const address = hostAndPort.slice(1, closeIndex);
|
||||
const normalizedAddress = normalizeIpv6(address);
|
||||
if (normalizedAddress === null) throw new TypeError("Invalid URL");
|
||||
hostname = `[${normalizedAddress}]`;
|
||||
const suffix = hostAndPort.slice(closeIndex + 1);
|
||||
if (suffix) {
|
||||
if (!suffix.startsWith(":")) throw new TypeError("Invalid URL");
|
||||
port = suffix.length === 1 ? "" : validatePort(suffix.slice(1));
|
||||
}
|
||||
} else {
|
||||
const firstColon = hostAndPort.indexOf(":");
|
||||
const lastColon = hostAndPort.lastIndexOf(":");
|
||||
if (firstColon !== lastColon) throw new TypeError("Invalid URL");
|
||||
hostname = firstColon < 0 ? hostAndPort : hostAndPort.slice(0, firstColon);
|
||||
const portValue = firstColon < 0 ? "" : hostAndPort.slice(firstColon + 1);
|
||||
port = portValue ? validatePort(portValue) : "";
|
||||
const normalizedHostname = normalizeHostname(hostname);
|
||||
if (!normalizedHostname) throw new TypeError("Invalid URL");
|
||||
hostname = normalizedHostname;
|
||||
}
|
||||
|
||||
return { username, password, hostname: hostname.toLowerCase(), port };
|
||||
}
|
||||
|
||||
function validatePort(value: string): string {
|
||||
if (!/^(?:0|[1-9]\d{0,4})$/u.test(value)) throw new TypeError("Invalid URL");
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port > 65_535) throw new TypeError("Invalid URL");
|
||||
return String(port);
|
||||
}
|
||||
|
||||
function normalizeHostname(value: string): string | null {
|
||||
const ascii = toASCII(value, {
|
||||
checkHyphens: false,
|
||||
checkBidi: true,
|
||||
checkJoiners: true,
|
||||
useSTD3ASCIIRules: false,
|
||||
transitionalProcessing: false,
|
||||
verifyDNSLength: false,
|
||||
ignoreInvalidPunycode: false,
|
||||
});
|
||||
if (!ascii || hasForbiddenUrlCharacter(ascii) || /[#%/:<>?@[\\\]^|]/u.test(ascii)) return null;
|
||||
const candidate = ascii.endsWith(".") ? ascii.slice(0, -1) : ascii;
|
||||
if (!candidate || candidate.length > 253 || !/^[A-Za-z0-9._-]+$/u.test(candidate)) return null;
|
||||
if (looksNumeric(candidate)) {
|
||||
const rawCandidate = value.endsWith(".") ? value.slice(0, -1) : value;
|
||||
return isStrictIpv4(candidate) && rawCandidate === candidate ? ascii.toLowerCase() : null;
|
||||
}
|
||||
const isValid = candidate
|
||||
.split(".")
|
||||
.every((part) => Boolean(part) && part.length <= 63 && !part.startsWith("-") && !part.endsWith("-"));
|
||||
return isValid ? ascii.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function hasForbiddenUrlCharacter(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return (
|
||||
code <= 0x20 ||
|
||||
(code >= 0x7f && code <= 0xa0) ||
|
||||
code === 0x1680 ||
|
||||
(code >= 0x2000 && code <= 0x200f) ||
|
||||
(code >= 0x2028 && code <= 0x202f) ||
|
||||
(code >= 0x205f && code <= 0x206f) ||
|
||||
code === 0x3000 ||
|
||||
code === 0xfeff
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeIpv6(value: string): string | null {
|
||||
if (value.includes("%")) return null;
|
||||
const address = value;
|
||||
if (!address.includes(":") || address.includes(":::") || /[^0-9A-Fa-f:.]/u.test(address)) return null;
|
||||
if (address.indexOf("::") !== address.lastIndexOf("::")) return null;
|
||||
if ((address.startsWith(":") && !address.startsWith("::")) || (address.endsWith(":") && !address.endsWith("::"))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasCompression = address.includes("::");
|
||||
const [left = "", right = ""] = hasCompression ? address.split("::") : [address, ""];
|
||||
if (hasCompression && left.includes(".")) return null;
|
||||
const leftGroups = parseIpv6Groups(left);
|
||||
const rightGroups = parseIpv6Groups(right);
|
||||
if (leftGroups === null || rightGroups === null) return null;
|
||||
|
||||
const explicitGroupCount = leftGroups.length + rightGroups.length;
|
||||
if ((!hasCompression && explicitGroupCount !== 8) || (hasCompression && explicitGroupCount >= 8)) return null;
|
||||
|
||||
const groups = hasCompression
|
||||
? [...leftGroups, ...Array.from({ length: 8 - explicitGroupCount }, () => 0), ...rightGroups]
|
||||
: leftGroups;
|
||||
let bestStart = -1;
|
||||
let bestLength = 1;
|
||||
for (let index = 0; index < groups.length;) {
|
||||
if (groups[index] !== 0) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let end = index + 1;
|
||||
while (end < groups.length && groups[end] === 0) end += 1;
|
||||
if (end - index > bestLength) {
|
||||
bestStart = index;
|
||||
bestLength = end - index;
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
|
||||
const hexadecimalGroups = groups.map((group) => group.toString(16));
|
||||
if (bestStart < 0) return hexadecimalGroups.join(":");
|
||||
const before = hexadecimalGroups.slice(0, bestStart).join(":");
|
||||
const after = hexadecimalGroups.slice(bestStart + bestLength).join(":");
|
||||
return `${before}::${after}`;
|
||||
}
|
||||
|
||||
function parseIpv6Groups(value: string): number[] | null {
|
||||
if (!value) return [];
|
||||
const groups = value.split(":");
|
||||
const parsed: number[] = [];
|
||||
for (const [index, group] of groups.entries()) {
|
||||
if (group.includes(".")) {
|
||||
if (index !== groups.length - 1 || !isStrictIpv4(group)) return null;
|
||||
const parts = group.split(".").map(Number);
|
||||
parsed.push(((parts[0] ?? 0) << 8) | (parts[1] ?? 0), ((parts[2] ?? 0) << 8) | (parts[3] ?? 0));
|
||||
} else {
|
||||
if (!/^[0-9A-Fa-f]{1,4}$/u.test(group)) return null;
|
||||
parsed.push(Number.parseInt(group, 16));
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isStrictIpv4(value: string): boolean {
|
||||
const parts = value.split(".");
|
||||
return parts.length === 4 && parts.every((part) => /^(?:0|[1-9]\d{0,2})$/u.test(part) && Number(part) <= 255);
|
||||
}
|
||||
|
||||
function looksNumeric(value: string): boolean {
|
||||
return value.split(".").every((part) => /^(?:\d+|0[xX][0-9A-Fa-f]+)$/u.test(part));
|
||||
}
|
||||
|
||||
function createSearchParams(search: string): PortableSearchParams {
|
||||
const keys = search
|
||||
? search
|
||||
.slice(1)
|
||||
.split("&")
|
||||
.map((entry) => entry.split("=", 1)[0] ?? "")
|
||||
.map(decodeQueryPart)
|
||||
: [];
|
||||
return { keys: () => [...keys] };
|
||||
}
|
||||
|
||||
function decodeQueryPart(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value.replaceAll("+", " "));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
declare module "tr46" {
|
||||
interface ToAsciiOptions {
|
||||
checkHyphens?: boolean;
|
||||
checkBidi?: boolean;
|
||||
checkJoiners?: boolean;
|
||||
useSTD3ASCIIRules?: boolean;
|
||||
transitionalProcessing?: boolean;
|
||||
verifyDNSLength?: boolean;
|
||||
ignoreInvalidPunycode?: boolean;
|
||||
}
|
||||
|
||||
export function toASCII(value: string, options?: ToAsciiOptions): string | null;
|
||||
}
|
||||
Generated
-3
@@ -2949,9 +2949,6 @@ importers:
|
||||
pocketbase:
|
||||
specifier: 'catalog:'
|
||||
version: 0.26.9
|
||||
tr46:
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.7
|
||||
|
||||
Reference in new issue
Block a user