@voidbase-cloud/voidbase 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/COMPAT.md +43 -0
- package/LICENSE +21 -0
- package/NOTICE +8 -0
- package/README.md +124 -0
- package/bin/voidbase.ts +158 -0
- package/crons/every-minute.ts +13 -0
- package/db/migrations/20260905175935_large_swarm.sql +87 -0
- package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
- package/db/migrations/20260905190723_solid_toro.sql +1 -0
- package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
- package/db/migrations/meta/20260905175935_snapshot.json +599 -0
- package/db/migrations/meta/20260905185720_snapshot.json +703 -0
- package/db/migrations/meta/20260905190723_snapshot.json +710 -0
- package/db/migrations/meta/20260905213340_snapshot.json +781 -0
- package/db/migrations/meta/_journal.json +34 -0
- package/db/schema.ts +130 -0
- package/docs/deploy.md +153 -0
- package/docs/differences.md +88 -0
- package/docs/hooks.md +84 -0
- package/docs/migrating.md +29 -0
- package/docs/perf.md +53 -0
- package/docs/platform.md +208 -0
- package/docs/releasing.md +38 -0
- package/env.ts +23 -0
- package/hooks-plugin.ts +237 -0
- package/package.json +134 -0
- package/queues/jobs.ts +13 -0
- package/routes/api/[...path].ts +19 -0
- package/scripts/bench-realtime.ts +46 -0
- package/scripts/bench.ts +39 -0
- package/scripts/ci-suites.sh +27 -0
- package/scripts/dev.sh +29 -0
- package/scripts/export.ts +70 -0
- package/scripts/seed-app-user.sh +14 -0
- package/scripts/seed-d1.ts +17 -0
- package/scripts/seed-reference.sh +29 -0
- package/scripts/starter.sh +22 -0
- package/scripts/sync-app.ts +22 -0
- package/scripts/sync-panel.ts +66 -0
- package/src/cloud/rest.ts +297 -0
- package/src/node/assets.ts +22 -0
- package/src/node/bundle.ts +88 -0
- package/src/node/cloud-init.ts +51 -0
- package/src/node/d1.ts +44 -0
- package/src/node/deploy-cf.ts +179 -0
- package/src/node/index.ts +5 -0
- package/src/node/panel.ts +21 -0
- package/src/node/serve.ts +125 -0
- package/src/node/storage.ts +51 -0
- package/src/platform/node/env.ts +4 -0
- package/src/platform/node/hooks.ts +19 -0
- package/src/platform/node/log.ts +7 -0
- package/src/platform/node/migrations.ts +5 -0
- package/src/platform/node/photon.ts +1 -0
- package/src/platform/node/sockets.ts +22 -0
- package/src/platform/node/sse.ts +23 -0
- package/src/platform/workers/env.ts +3 -0
- package/src/platform/workers/hooks.ts +2 -0
- package/src/platform/workers/log.ts +1 -0
- package/src/platform/workers/migrations.ts +1 -0
- package/src/platform/workers/photon.ts +1 -0
- package/src/platform/workers/sockets.ts +3 -0
- package/src/platform/workers/sse.ts +1 -0
- package/src/server/api.ts +27 -0
- package/src/server/app.ts +582 -0
- package/src/server/auth-extra.ts +113 -0
- package/src/server/auth-flows.ts +186 -0
- package/src/server/auth-response.ts +111 -0
- package/src/server/auth.ts +187 -0
- package/src/server/backups.ts +234 -0
- package/src/server/batch.ts +123 -0
- package/src/server/bootstrap.ts +71 -0
- package/src/server/collections/auth-option-shape.json +71 -0
- package/src/server/collections/ddl.ts +127 -0
- package/src/server/collections/fields.ts +120 -0
- package/src/server/collections/model.ts +185 -0
- package/src/server/collections/oauth2-providers.json +1 -0
- package/src/server/collections/scaffolds.json +210 -0
- package/src/server/collections/service.ts +392 -0
- package/src/server/collections/system.json +605 -0
- package/src/server/collections/system.ts +19 -0
- package/src/server/collections/validate.ts +239 -0
- package/src/server/crc32.ts +13 -0
- package/src/server/crons.ts +100 -0
- package/src/server/crypto.ts +26 -0
- package/src/server/db.ts +37 -0
- package/src/server/errors.ts +53 -0
- package/src/server/files-api.ts +52 -0
- package/src/server/filter/compile.ts +420 -0
- package/src/server/filter/lexer.ts +107 -0
- package/src/server/filter/parser.ts +49 -0
- package/src/server/hardening.ts +136 -0
- package/src/server/hooks/index.ts +147 -0
- package/src/server/hooks/migrations.ts +58 -0
- package/src/server/hooks/node-async-hooks.d.ts +7 -0
- package/src/server/hooks/record.ts +152 -0
- package/src/server/hooks/runtime.ts +344 -0
- package/src/server/hooks/virtual-migrations.d.ts +4 -0
- package/src/server/hooks/virtual.d.ts +7 -0
- package/src/server/hub.ts +91 -0
- package/src/server/ids.ts +22 -0
- package/src/server/jobs.ts +84 -0
- package/src/server/jwt.ts +61 -0
- package/src/server/logs.ts +144 -0
- package/src/server/mail/index.ts +99 -0
- package/src/server/mail/message.ts +43 -0
- package/src/server/mail/smtp.ts +82 -0
- package/src/server/mail/templates.ts +168 -0
- package/src/server/oauth2/index.ts +198 -0
- package/src/server/oauth2/providers.ts +153 -0
- package/src/server/password.ts +17 -0
- package/src/server/realtime/hub-client.ts +50 -0
- package/src/server/realtime/index.ts +239 -0
- package/src/server/records/expand.ts +129 -0
- package/src/server/records/files.ts +69 -0
- package/src/server/records/json.ts +23 -0
- package/src/server/records/picker.ts +80 -0
- package/src/server/records/service.ts +598 -0
- package/src/server/records/thumbs.ts +148 -0
- package/src/server/records/values.ts +295 -0
- package/src/server/settings-api.ts +104 -0
- package/src/server/settings.ts +215 -0
- package/src/server/sql.ts +61 -0
- package/src/server/static.ts +17 -0
- package/src/server/storage/s3.ts +118 -0
- package/src/server/types.ts +25 -0
- package/src/server/webauthn.ts +168 -0
- package/tsconfig.json +36 -0
- package/tsconfig.node.json +27 -0
- package/types/pb_data.d.ts +24438 -0
- package/vite.config.ts +10 -0
- package/void.json +12 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Collection validation mirroring PocketBase core/collection_validate.go (codes and messages).
|
|
2
|
+
import type { FieldErrors } from "../errors";
|
|
3
|
+
import { compileFilter, FilterError } from "../filter/compile";
|
|
4
|
+
import { FilterSyntaxError } from "../filter/lexer";
|
|
5
|
+
import { parseIndex } from "./ddl";
|
|
6
|
+
import { FIELD_TYPES, isFieldType, type Field } from "./fields";
|
|
7
|
+
import type { Collection } from "./model";
|
|
8
|
+
|
|
9
|
+
type Errs = Record<string, unknown>; // nested: { name: {code,message}, fields: { "1": { name: {...} } } }
|
|
10
|
+
const err = (code: string, message: string) => ({ code, message });
|
|
11
|
+
|
|
12
|
+
const NAME_RE = /^\w+$/;
|
|
13
|
+
const ID_RE = /^[^@#$&|.,'"\\/\s]+$/;
|
|
14
|
+
const RESERVED_FIELD_NAMES = ["collectionId", "collectionName", "expand"];
|
|
15
|
+
const RESERVED_AUTH_KEYS = ["passwordConfirm", "oldPassword"];
|
|
16
|
+
const INTERNAL_TABLES = ["_collections", "_params", "_migrations", "_pbMigrations", "_void_migrations", "_changes", "_realtime_clients", "_logs"];
|
|
17
|
+
|
|
18
|
+
export interface ValidateContext {
|
|
19
|
+
all: Collection[]; // every known collection (including ones being imported in the same batch)
|
|
20
|
+
usedIndexNames: Map<string, string>; // lowercase index name -> table name (from sqlite_master)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateCollection(c: Collection, old: Collection | null, ctx: ValidateContext): Errs {
|
|
24
|
+
const errs: Errs = {};
|
|
25
|
+
const isNew = !old;
|
|
26
|
+
|
|
27
|
+
const nameErr = validateName(c, old, ctx);
|
|
28
|
+
if (nameErr) errs.name = nameErr;
|
|
29
|
+
|
|
30
|
+
if (!c.id) errs.id = err("validation_required", "Cannot be blank.");
|
|
31
|
+
else if (isNew && (c.id.length > 100 || !ID_RE.test(c.id))) errs.id = err("validation_match_invalid", "Must be in a valid format.");
|
|
32
|
+
else if (isNew && !nameErr && ctx.all.some((x) => x !== c && x.id === c.id)) errs.id = err("validation_invalid_id", "The model id is invalid or already exists.");
|
|
33
|
+
else if (!isNew && c.id !== old.id) errs.id = err("validation_values_mismatch", "Values don't match.");
|
|
34
|
+
|
|
35
|
+
if (!isNew && c.system !== old.system) errs.system = err("validation_collection_system_flag_change", "System collection state cannot be changed.");
|
|
36
|
+
|
|
37
|
+
if (!c.type) errs.type = err("validation_required", "Cannot be blank.");
|
|
38
|
+
else if (!["base", "auth", "view"].includes(c.type)) errs.type = err("validation_in_invalid", "Must be a valid value.");
|
|
39
|
+
else if (!isNew && c.type !== old.type) errs.type = err("validation_collection_type_change", "Collection type cannot be changed.");
|
|
40
|
+
|
|
41
|
+
const fieldsErr = validateFields(c, old);
|
|
42
|
+
if (fieldsErr) errs.fields = fieldsErr;
|
|
43
|
+
|
|
44
|
+
const idxErr = validateIndexes(c, old, ctx);
|
|
45
|
+
if (idxErr) errs.indexes = idxErr;
|
|
46
|
+
|
|
47
|
+
// API rules: view collections cannot have write rules (ozzo Nil), every rule must compile against the
|
|
48
|
+
// collection's own fields (checkRule), system collection rules are frozen (ensureNoSystemRuleChange).
|
|
49
|
+
const frozen = !isNew && old.system;
|
|
50
|
+
const ruleChange = (nv: unknown, ov: unknown) => (frozen && (nv ?? null) !== (ov ?? null) ? err("validation_collection_system_rule_change", "System collection API rule cannot be changed.") : null);
|
|
51
|
+
for (const k of ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"] as const) {
|
|
52
|
+
const v = (c[k] ?? null) as string | null;
|
|
53
|
+
if (c.type === "view" && k !== "listRule" && k !== "viewRule" && v !== null) errs[k] = err("validation_nil", "Must be blank.");
|
|
54
|
+
else errs[k] = ruleError(v, c, ctx) ?? ruleChange(v, old?.[k]);
|
|
55
|
+
if (!errs[k]) delete errs[k];
|
|
56
|
+
}
|
|
57
|
+
if (c.type === "auth") {
|
|
58
|
+
const opts = c.options as { authRule?: string | null; manageRule?: string | null; mfa?: { enabled?: boolean; rule?: string } };
|
|
59
|
+
const oldOpts = (old?.options ?? {}) as typeof opts;
|
|
60
|
+
const authRuleErr = ruleError(opts.authRule ?? null, c, ctx) ?? ruleChange(opts.authRule, oldOpts.authRule);
|
|
61
|
+
if (authRuleErr) errs.authRule = authRuleErr;
|
|
62
|
+
const manageRuleErr = opts.manageRule === "" ? err("validation_nil_or_not_empty_required", "Cannot be blank.") : ruleError(opts.manageRule ?? null, c, ctx) ?? ruleChange(opts.manageRule, oldOpts.manageRule);
|
|
63
|
+
if (manageRuleErr) errs.manageRule = manageRuleErr;
|
|
64
|
+
// collection_model_auth_options.go returns the struct errors first; the mfa rule is only checked after they pass
|
|
65
|
+
const mfaRule = opts.mfa?.rule ?? "";
|
|
66
|
+
if (opts.mfa?.enabled && mfaRule && !authRuleErr && !manageRuleErr) { const e = ruleError(mfaRule, c, ctx) ?? ruleChange(mfaRule, oldOpts.mfa?.rule ?? ""); if (e) errs.mfa = { rule: e }; }
|
|
67
|
+
}
|
|
68
|
+
// Go serializes validation.Errors (a map) with sorted keys
|
|
69
|
+
return Object.fromEntries(Object.entries(errs).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// core/collection_validate.go checkRule: a dry compile with an empty request (no auth) - the same resolver the
|
|
73
|
+
// records API uses, so unknown fields, bad relation paths and syntax errors surface at save time.
|
|
74
|
+
function ruleError(rule: string | null, c: Collection, ctx: ValidateContext) {
|
|
75
|
+
if (rule === null || rule === "") return null;
|
|
76
|
+
const collections = new Map<string, Collection>();
|
|
77
|
+
for (const x of ctx.all) { collections.set(x.id, x); collections.set(x.name, x); }
|
|
78
|
+
try {
|
|
79
|
+
compileFilter(rule, { base: c, collections, request: { auth: null, method: "GET", query: {}, headers: {}, body: {}, context: "default" }, allowHiddenFields: true });
|
|
80
|
+
return null;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// search.FilterData.BuildExpr: parse failures collapse to one message; resolver failures keep their text
|
|
83
|
+
const raw = e instanceof FilterSyntaxError ? "invalid or incomplete filter expression" : e instanceof FilterError ? e.message : null;
|
|
84
|
+
if (raw === null) throw e;
|
|
85
|
+
return err("validation_invalid_rule", `Invalid rule. Raw error: ${/[.!?]$/.test(raw) ? raw : raw + "."}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateName(c: Collection, old: Collection | null, ctx: ValidateContext) {
|
|
90
|
+
if (!c.name) return err("validation_required", "Cannot be blank.");
|
|
91
|
+
if (c.name.length > 255) return err("validation_length_out_of_range", "The length must be between 1 and 255.");
|
|
92
|
+
if (c.name.includes("_via_")) return err("validation_found_via", `The value cannot contain "_via_".`);
|
|
93
|
+
if (!NAME_RE.test(c.name)) return err("validation_match_invalid", "Must be in a valid format.");
|
|
94
|
+
if (old?.system && old.name !== c.name) return err("validation_collection_system_name_change", "System collection name cannot be changed.");
|
|
95
|
+
const lower = c.name.toLowerCase();
|
|
96
|
+
if (ctx.all.some((x) => x !== c && x.name.toLowerCase() === lower)) {
|
|
97
|
+
return err("validation_collection_name_exists", "Collection name must be unique (case insensitive).");
|
|
98
|
+
}
|
|
99
|
+
if (ctx.all.some((x) => x !== c && x.id.toLowerCase() === lower)) {
|
|
100
|
+
return err("validation_collection_name_id_duplicate", "The name must not match an existing collection id.");
|
|
101
|
+
}
|
|
102
|
+
if (INTERNAL_TABLES.some((t) => t.toLowerCase() === lower) || lower.startsWith("sqlite_")) {
|
|
103
|
+
return err("validation_collection_name_invalid", "The name shouldn't match with an existing internal table.");
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateFields(c: Collection, old: Collection | null): Errs | { code: string; message: string } | null {
|
|
109
|
+
const fields = c.fields as Field[];
|
|
110
|
+
const ids = new Set<string>();
|
|
111
|
+
const names = new Set<string>();
|
|
112
|
+
const perField: Errs = {};
|
|
113
|
+
const exclude = [...RESERVED_FIELD_NAMES, ...(c.type === "auth" ? [] : [])];
|
|
114
|
+
|
|
115
|
+
fields.forEach((f, i) => {
|
|
116
|
+
const fe: Errs = {};
|
|
117
|
+
if (!f.type || !isFieldType(f.type)) fe.type = err("validation_in_invalid", `Must be one of ${FIELD_TYPES.join(", ")}.`);
|
|
118
|
+
if (ids.has(f.id)) return void (perField[String(i)] = { id: err("validation_duplicated_field_id", `Duplicated or invalid field id "${f.id}"`) });
|
|
119
|
+
ids.add(f.id);
|
|
120
|
+
const lower = f.name.toLowerCase();
|
|
121
|
+
if (names.has(lower)) return void (perField[String(i)] = { name: err("validation_duplicated_field_name", `Duplicated or invalid field name ${f.name}`) });
|
|
122
|
+
names.add(lower);
|
|
123
|
+
if (!f.name) fe.name = err("validation_required", "Cannot be blank.");
|
|
124
|
+
else if (f.name.length > 100) fe.name = err("validation_length_out_of_range", "The length must be between 1 and 100.");
|
|
125
|
+
else if (!NAME_RE.test(f.name)) fe.name = err("validation_match_invalid", "Must be in a valid format.");
|
|
126
|
+
else if (exclude.includes(f.name)) fe.name = err("validation_not_in_invalid", "Must not be in list.");
|
|
127
|
+
else if (f.name.includes("_via_")) fe.name = err("validation_found_via", `The value cannot contain "_via_".`);
|
|
128
|
+
else if (c.type === "auth" && RESERVED_AUTH_KEYS.includes(f.name)) fe.name = err("validation_reserved_field_name", "The field name is reserved and cannot be used.");
|
|
129
|
+
if (f.help && String(f.help).length > 300) fe.help = err("validation_length_out_of_range", "The length must be between 0 and 300.");
|
|
130
|
+
if (old) {
|
|
131
|
+
const of = (old.fields as Field[]).find((x) => x.id === f.id);
|
|
132
|
+
if (of && of.type !== f.type) fe.type = err("validation_field_type_change", "Field type cannot be changed.");
|
|
133
|
+
}
|
|
134
|
+
Object.assign(fe, fieldSettingsErrors(f, c));
|
|
135
|
+
if (Object.keys(fe).length) perField[String(i)] = fe;
|
|
136
|
+
});
|
|
137
|
+
if (Object.keys(perField).length) return perField;
|
|
138
|
+
|
|
139
|
+
if (c.type !== "view") {
|
|
140
|
+
const pk = fields.find((f) => f.name === "id");
|
|
141
|
+
if (!pk || pk.type !== "text" || !pk.primaryKey || !pk.system) return err("validation_missing_primary_key", `Missing or invalid "id" PK field.`);
|
|
142
|
+
if (c.type === "auth") {
|
|
143
|
+
const need: Array<[string, string, string]> = [
|
|
144
|
+
["password", "password", "validation_missing_password_field"],
|
|
145
|
+
["tokenKey", "text", "validation_missing_tokenKey_field"],
|
|
146
|
+
["email", "email", "validation_missing_email_field"],
|
|
147
|
+
["emailVisibility", "bool", "validation_missing_emailVisibility_field"],
|
|
148
|
+
["verified", "bool", "validation_missing_verified_field"],
|
|
149
|
+
];
|
|
150
|
+
for (const [name, type, code] of need) {
|
|
151
|
+
const f = fields.find((x) => x.name === name);
|
|
152
|
+
if (!f || f.type !== type || !f.system) return err(code, `System "${name}" field is required.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (old) {
|
|
156
|
+
for (const of of old.fields as Field[]) {
|
|
157
|
+
if (!of.system) continue;
|
|
158
|
+
const nf = fields.find((x) => x.id === of.id);
|
|
159
|
+
if (!nf || nf.name !== of.name) return err("validation_system_field_change", "System fields cannot be deleted or renamed.");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function fieldSettingsErrors(f: Field, c: Collection): Errs {
|
|
167
|
+
const e: Errs = {};
|
|
168
|
+
const n = (v: unknown) => (v === null || v === undefined ? null : Number(v));
|
|
169
|
+
switch (f.type) {
|
|
170
|
+
case "text":
|
|
171
|
+
if (f.primaryKey && f.name !== "id") e.name = err("validation_in_invalid", "Must be a valid value.");
|
|
172
|
+
if ((n(f.min) ?? 0) < 0) e.min = err("validation_min_greater_equal_than_required", "Must be no less than 0.");
|
|
173
|
+
if ((n(f.max) ?? 0) < 0 || ((n(f.max) ?? 0) > 0 && (n(f.max) ?? 0) < (n(f.min) ?? 0))) e.max = err("validation_min_greater_equal_than_required", `Must be no less than ${n(f.min) ?? 0}.`);
|
|
174
|
+
break;
|
|
175
|
+
case "number":
|
|
176
|
+
if (f.onlyInt) {
|
|
177
|
+
if (n(f.min) !== null && !Number.isInteger(n(f.min))) e.min = err("validation_only_int_constraint", "Must be an integer.");
|
|
178
|
+
if (n(f.max) !== null && !Number.isInteger(n(f.max))) e.max = err("validation_only_int_constraint", "Must be an integer.");
|
|
179
|
+
}
|
|
180
|
+
if (n(f.min) !== null && n(f.max) !== null && (n(f.max) as number) < (n(f.min) as number)) e.max = err("validation_min_greater_equal_than_required", `Must be no less than ${n(f.min)}.`);
|
|
181
|
+
break;
|
|
182
|
+
case "select": {
|
|
183
|
+
const values = Array.isArray(f.values) ? f.values : [];
|
|
184
|
+
if (values.length === 0) e.values = err("validation_required", "Cannot be blank.");
|
|
185
|
+
if ((n(f.maxSelect) ?? 0) < 0 || (n(f.maxSelect) ?? 0) > values.length) e.maxSelect = err("validation_max_less_equal_than_required", `Must be no greater than ${values.length}.`);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case "file":
|
|
189
|
+
if ((n(f.maxSelect) ?? 0) < 0) e.maxSelect = err("validation_min_greater_equal_than_required", "Must be no less than 0.");
|
|
190
|
+
if ((n(f.maxSize) ?? 0) < 0) e.maxSize = err("validation_min_greater_equal_than_required", "Must be no less than 0.");
|
|
191
|
+
break;
|
|
192
|
+
case "relation":
|
|
193
|
+
if (!f.collectionId) e.collectionId = err("validation_required", "Cannot be blank.");
|
|
194
|
+
if ((n(f.minSelect) ?? 0) < 0) e.minSelect = err("validation_min_greater_equal_than_required", "Must be no less than 0.");
|
|
195
|
+
if ((n(f.maxSelect) ?? 0) < 0 || ((n(f.maxSelect) ?? 0) > 0 && (n(f.maxSelect) ?? 0) < (n(f.minSelect) ?? 0))) e.maxSelect = err("validation_min_greater_equal_than_required", `Must be no less than ${n(f.minSelect) ?? 0}.`);
|
|
196
|
+
break;
|
|
197
|
+
case "editor": case "json":
|
|
198
|
+
if ((n(f.maxSize) ?? 0) < 0) e.maxSize = err("validation_min_greater_equal_than_required", "Must be no less than 0.");
|
|
199
|
+
break;
|
|
200
|
+
case "password": {
|
|
201
|
+
// ozzo-validation skips zero values for Min/Max, so 0 means "unset"
|
|
202
|
+
const min = n(f.min) ?? 0;
|
|
203
|
+
const max = n(f.max) ?? 0;
|
|
204
|
+
if (min !== 0 && (min < 1 || min > 71)) e.min = err("validation_min_greater_equal_than_required", "Must be between 1 and 71.");
|
|
205
|
+
if (max !== 0 && (max < min || max > 71)) e.max = err("validation_max_less_equal_than_required", `Must be between ${min} and 71.`);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
case "autodate":
|
|
209
|
+
if (!f.onCreate && !f.onUpdate) e.onCreate = err("validation_required", "Cannot be blank.");
|
|
210
|
+
break;
|
|
211
|
+
case "date":
|
|
212
|
+
if (f.min && f.max && String(f.max) < String(f.min)) e.max = err("validation_min_greater_equal_than_required", `Must be no less than ${f.min}.`);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
void c;
|
|
216
|
+
return e;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateIndexes(c: Collection, old: Collection | null, ctx: ValidateContext): Errs | { code: string; message: string } | null {
|
|
220
|
+
if (c.type === "view" && c.indexes.length > 0) return err("validation_indexes_not_supported", "View collections don't support indexes.");
|
|
221
|
+
const names = new Set<string>();
|
|
222
|
+
const defs = new Set<string>();
|
|
223
|
+
const per: Errs = {};
|
|
224
|
+
c.indexes.forEach((raw, i) => {
|
|
225
|
+
const p = parseIndex(raw);
|
|
226
|
+
if (!p || !p.name || !p.columns) return void (per[String(i)] = err("validation_invalid_index_expression", "Invalid CREATE INDEX expression."));
|
|
227
|
+
const lower = p.name.toLowerCase();
|
|
228
|
+
if (names.has(lower)) return void (per[String(i)] = err("validation_duplicated_index_name", "The index name already exists."));
|
|
229
|
+
names.add(lower);
|
|
230
|
+
const usedBy = ctx.usedIndexNames.get(lower);
|
|
231
|
+
if (usedBy && usedBy.toLowerCase() !== c.name.toLowerCase() && usedBy.toLowerCase() !== (old?.name ?? "").toLowerCase()) {
|
|
232
|
+
return void (per[String(i)] = err("validation_existing_index_name", `The index name is already used in ${usedBy} collection.`));
|
|
233
|
+
}
|
|
234
|
+
const def = `${p.unique}|${p.columns.replace(/\s+/g, "").toLowerCase()}|${p.where.toLowerCase()}`;
|
|
235
|
+
if (defs.has(def)) return void (per[String(i)] = err("validation_duplicated_index_definition", "The index definition already exists."));
|
|
236
|
+
defs.add(def);
|
|
237
|
+
});
|
|
238
|
+
return Object.keys(per).length ? per : null;
|
|
239
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// IEEE CRC32 as an unsigned decimal string (PocketBase uses crc32.ChecksumIEEE for field ids).
|
|
2
|
+
const table = new Uint32Array(256);
|
|
3
|
+
for (let i = 0; i < 256; i++) {
|
|
4
|
+
let c = i;
|
|
5
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
6
|
+
table[i] = c >>> 0;
|
|
7
|
+
}
|
|
8
|
+
export function crc32(input: string): string {
|
|
9
|
+
const bytes = new TextEncoder().encode(input);
|
|
10
|
+
let crc = 0xffffffff;
|
|
11
|
+
for (const b of bytes) crc = table[(crc ^ b) & 0xff]! ^ (crc >>> 8);
|
|
12
|
+
return String((crc ^ 0xffffffff) >>> 0);
|
|
13
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Cron registry (apis/cron.go + core built-ins): jobs registered by hooks (cronAdd) plus PocketBase's own
|
|
2
|
+
// maintenance jobs. Cloudflare fires crons/every-minute.ts once a minute; runDue matches every job's expression
|
|
3
|
+
// against that minute. The superuser API lists jobs and runs one on demand.
|
|
4
|
+
import type { Hono } from "hono";
|
|
5
|
+
import { logger } from "#platform/log";
|
|
6
|
+
import { requireSuperuser } from "./auth";
|
|
7
|
+
import { run } from "./db";
|
|
8
|
+
import { notFound } from "./errors";
|
|
9
|
+
import { crons as hookCrons } from "./hooks/runtime";
|
|
10
|
+
import { withHookStore } from "./hooks/migrations";
|
|
11
|
+
import { nowString } from "./ids";
|
|
12
|
+
import { deleteOldLogs } from "./logs";
|
|
13
|
+
import { autoBackup } from "./backups";
|
|
14
|
+
import { attachJobs } from "./jobs";
|
|
15
|
+
import { loadSettings } from "./settings";
|
|
16
|
+
import { s3Bucket } from "./storage/s3";
|
|
17
|
+
import type { AppEnv } from "./types";
|
|
18
|
+
|
|
19
|
+
export interface CronJob { id: string; expr: string; fn: (env: AppEnv["Bindings"]) => Promise<unknown> | unknown }
|
|
20
|
+
|
|
21
|
+
const BUILTIN: CronJob[] = [
|
|
22
|
+
{ id: "__pbDBOptimize__", expr: "0 0 * * *", fn: async () => undefined /* D1 maintains itself */ },
|
|
23
|
+
{ id: "__pbMFACleanup__", expr: "0 * * * *", fn: async (env) => { await run(env.DB, "DELETE FROM `_mfas` WHERE created < ?", [nowString(new Date(Date.now() - 24 * 3600_000))]); } },
|
|
24
|
+
{ id: "__pbOTPCleanup__", expr: "0 * * * *", fn: async (env) => { await run(env.DB, "DELETE FROM `_otps` WHERE created < ?", [nowString(new Date(Date.now() - 24 * 3600_000))]); } },
|
|
25
|
+
{ id: "__pbLogsCleanup__", expr: "0 */6 * * *", fn: async (env) => deleteOldLogs(env.DB, (await loadSettings(env.DB)).logs.maxDays) },
|
|
26
|
+
// voidbase: the realtime change feed and stale stream rows only need to survive a few poll intervals
|
|
27
|
+
{ id: "__vbChangesCleanup__", expr: "*/10 * * * *", fn: async (env) => { await run(env.DB, "DELETE FROM `_changes` WHERE created < ?", [nowString(new Date(Date.now() - 10 * 60_000))]); await run(env.DB, "DELETE FROM `_realtime_clients` WHERE updated < ?", [nowString(new Date(Date.now() - 6 * 3600_000))]); } },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function allJobs(backupsCron = ""): CronJob[] {
|
|
31
|
+
const fromHooks: CronJob[] = [...hookCrons.entries()].map(([id, j]) => ({ id, expr: j.expr, fn: async () => j.fn() })).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
32
|
+
const auto: CronJob[] = backupsCron ? [{ id: "__pbAutoBackup__", expr: backupsCron, fn: (env) => autoBackup(env) }] : [];
|
|
33
|
+
// cronsList: user jobs alphabetically, then PocketBase's own jobs in registration order, then voidbase's
|
|
34
|
+
return [...fromHooks, ...BUILTIN, ...auto];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runJob(env: AppEnv["Bindings"], job: CronJob): Promise<void> {
|
|
38
|
+
const s3 = (await loadSettings(env.DB)).s3;
|
|
39
|
+
if (s3.enabled) env = { ...env, STORAGE: s3Bucket(s3) };
|
|
40
|
+
try { await withHookStore(env.DB, env, () => job.fn(env)); } catch (err) { logger.error("voidbase: cron job failed", { job: job.id, error: err instanceof Error ? `${err.name}: ${err.message}` : String(err) }); }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// tools/cron matcher for one minute (UTC, like PocketBase)
|
|
44
|
+
export function matches(expr: string, date: Date): boolean {
|
|
45
|
+
const macros: Record<string, string> = { "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", "@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *" };
|
|
46
|
+
const segments = (macros[expr] ?? expr).split(" ");
|
|
47
|
+
if (segments.length !== 5) return false;
|
|
48
|
+
const values = [date.getUTCMinutes(), date.getUTCHours(), date.getUTCDate(), date.getUTCMonth() + 1, date.getUTCDay()];
|
|
49
|
+
const bounds: [number, number][] = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]];
|
|
50
|
+
return segments.every((seg, i) => seg.split(",").some((part) => {
|
|
51
|
+
const [range, stepStr] = part.split("/"); const step = stepStr ? Number(stepStr) : 1;
|
|
52
|
+
let lo = bounds[i]![0], hi = bounds[i]![1];
|
|
53
|
+
if (range !== "*") { const [a, b] = range!.split("-").map(Number); lo = a!; hi = b ?? a!; }
|
|
54
|
+
const v = values[i]!;
|
|
55
|
+
return v >= lo && v <= hi && (v - lo) % step === 0;
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function runDue(env: AppEnv["Bindings"], date: Date): Promise<string[]> {
|
|
60
|
+
attachJobs(env);
|
|
61
|
+
const ran: string[] = [];
|
|
62
|
+
const settings = await loadSettings(env.DB);
|
|
63
|
+
// triggers fire at the hook expressions and hourly: catch every job due since the previous tick (at most an hour)
|
|
64
|
+
const from = new Date(Math.max(lastTick ?? 0, date.getTime() - 3600_000)); lastTick = date.getTime();
|
|
65
|
+
for (const job of allJobs(settings.backups.cron)) {
|
|
66
|
+
const due = job.id.startsWith("__pb") || job.id.startsWith("__vb") ? matches(job.expr, date) || dueWithin(job.expr, from, date) : matches(job.expr, date) || dueWithin(job.expr, from, date);
|
|
67
|
+
if (due) { await runJob(env, job); ran.push(job.id); }
|
|
68
|
+
}
|
|
69
|
+
return ran;
|
|
70
|
+
}
|
|
71
|
+
let lastTick: number | null = null;
|
|
72
|
+
function dueWithin(expr: string, from: Date, to: Date): boolean {
|
|
73
|
+
for (let t = Math.ceil(from.getTime() / 60_000) * 60_000; t < Math.floor(to.getTime() / 60_000) * 60_000; t += 60_000) if (matches(expr, new Date(t))) return true;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Lazy maintenance: PocketBase's cleanups (and an overdue backups cron) run from a request at most once an hour per
|
|
78
|
+
// isolate, so an app with no cron hooks needs no trigger to stay tidy and an idle app runs nothing at all.
|
|
79
|
+
let lastMaintenance = 0;
|
|
80
|
+
export function maintenanceIfDue(env: AppEnv["Bindings"], waitUntil: (p: Promise<unknown>) => void): void {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
if (now - lastMaintenance < 3600_000) return;
|
|
83
|
+
lastMaintenance = now;
|
|
84
|
+
waitUntil((async () => {
|
|
85
|
+
const settings = await loadSettings(env.DB);
|
|
86
|
+
const since = new Date(now - 3600_000);
|
|
87
|
+
for (const job of allJobs(settings.backups.cron)) if (!hookCrons.has(job.id) && dueWithin(job.expr, since, new Date(now))) await runJob(env, job);
|
|
88
|
+
})());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function mountCronsApi(app: Hono<AppEnv>) {
|
|
92
|
+
app.get("/api/crons", async (c) => { requireSuperuser(c); return c.json(allJobs((await loadSettings(c.env.DB)).backups.cron).map((j) => ({ id: j.id, expression: j.expr }))); });
|
|
93
|
+
app.post("/api/crons/:id", async (c) => {
|
|
94
|
+
requireSuperuser(c);
|
|
95
|
+
const job = allJobs((await loadSettings(c.env.DB)).backups.cron).find((j) => j.id === c.req.param("id"));
|
|
96
|
+
if (!job) throw notFound("Missing or invalid cron job");
|
|
97
|
+
c.executionCtx.waitUntil(runJob(c.env, job));
|
|
98
|
+
return c.body(null, 204);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// AES-GCM at rest with the key from VOIDBASE_ENCRYPTION_KEY (PocketBase's --encryptionEnv shape: 16, 24 or 32 chars).
|
|
2
|
+
// Used for the settings row and, through voidbase/cloud, for what a control plane must keep (OAuth tokens).
|
|
3
|
+
const b64 = { enc: (b: Uint8Array) => btoa(String.fromCharCode(...b)), dec: (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)) };
|
|
4
|
+
const aesKey = (key: string) => crypto.subtle.importKey("raw", new TextEncoder().encode(key), { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
5
|
+
|
|
6
|
+
/** nonce || ciphertext || tag, base64 (like PocketBase's security.Encrypt) */
|
|
7
|
+
export async function aesSeal(plain: string, key: string): Promise<string> {
|
|
8
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
9
|
+
const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, await aesKey(key), new TextEncoder().encode(plain)));
|
|
10
|
+
const out = new Uint8Array(nonce.length + sealed.length); out.set(nonce); out.set(sealed, nonce.length);
|
|
11
|
+
return b64.enc(out);
|
|
12
|
+
}
|
|
13
|
+
export async function aesOpen(encoded: string, key: string): Promise<string> {
|
|
14
|
+
const bytes = b64.dec(encoded);
|
|
15
|
+
return new TextDecoder().decode(await crypto.subtle.decrypt({ name: "AES-GCM", iv: bytes.slice(0, 12) }, await aesKey(key), bytes.slice(12)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A secret stored in a record field: "enc:" + sealed, so a value written before a key existed still reads back.
|
|
19
|
+
const PREFIX = "enc:";
|
|
20
|
+
export const isSealed = (value: string): boolean => value.startsWith(PREFIX);
|
|
21
|
+
export async function sealSecret(plain: string, key: string): Promise<string> { return plain ? PREFIX + (await aesSeal(plain, key)) : ""; }
|
|
22
|
+
export async function openSecret(stored: string, key: string): Promise<string> {
|
|
23
|
+
if (!isSealed(stored)) return stored;
|
|
24
|
+
if (!key) throw new Error("the value is encrypted but VOIDBASE_ENCRYPTION_KEY is not set");
|
|
25
|
+
return aesOpen(stored.slice(PREFIX.length), key);
|
|
26
|
+
}
|
package/src/server/db.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Row } from "./types";
|
|
2
|
+
|
|
3
|
+
// Quote an identifier for SQLite. Rejects backticks outright rather than escaping them.
|
|
4
|
+
export function ident(name: string): string {
|
|
5
|
+
if (!/^\w+$/.test(name)) throw new Error(`invalid identifier: ${name}`);
|
|
6
|
+
return "`" + name + "`";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// D1 binds: null, number, string, boolean(as 0/1), ArrayBuffer. Everything else is JSON text.
|
|
10
|
+
export function bindValue(v: unknown): unknown {
|
|
11
|
+
if (v === undefined) return null;
|
|
12
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
13
|
+
if (v === null || typeof v === "number" || typeof v === "string" || v instanceof ArrayBuffer) return v;
|
|
14
|
+
return JSON.stringify(v);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function stmt(db: D1Database, sql: string, params: unknown[] = []): D1PreparedStatement {
|
|
18
|
+
return db.prepare(sql).bind(...params.map(bindValue));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function all<T = Row>(db: D1Database, sql: string, params: unknown[] = []): Promise<T[]> {
|
|
22
|
+
const r = await stmt(db, sql, params).all<T>();
|
|
23
|
+
return r.results ?? [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function one<T = Row>(db: D1Database, sql: string, params: unknown[] = []): Promise<T | null> {
|
|
27
|
+
return (await stmt(db, sql, params).first<T>()) ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function run(db: D1Database, sql: string, params: unknown[] = []): Promise<D1Result> {
|
|
31
|
+
return stmt(db, sql, params).run();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function batch(db: D1Database, statements: D1PreparedStatement[]): Promise<D1Result[]> {
|
|
35
|
+
if (statements.length === 0) return [];
|
|
36
|
+
return db.batch(statements);
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// PocketBase error envelope: { status, message, data }
|
|
2
|
+
// data holds per-field validation errors: { field: { code, message } }
|
|
3
|
+
export type FieldErrors = Record<string, { code: string; message: string }>;
|
|
4
|
+
|
|
5
|
+
// PocketBase passes messages through inflector.Sentenize: capitalized, ending with punctuation.
|
|
6
|
+
export function sentenize(message: string): string {
|
|
7
|
+
const m = message.trim();
|
|
8
|
+
if (!m) return m;
|
|
9
|
+
const cap = m[0]!.toUpperCase() + m.slice(1);
|
|
10
|
+
return /[.!?]$/.test(cap) ? cap : cap + ".";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ApiError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
public status: number,
|
|
16
|
+
message: string,
|
|
17
|
+
public data: FieldErrors | Record<string, unknown> = {},
|
|
18
|
+
) {
|
|
19
|
+
super(sentenize(message));
|
|
20
|
+
}
|
|
21
|
+
toJSON() {
|
|
22
|
+
return { data: this.data, message: this.message, status: this.status };
|
|
23
|
+
}
|
|
24
|
+
response() {
|
|
25
|
+
return jsonResponse(this.toJSON(), { status: this.status });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const badRequest = (message = "Something went wrong while processing your request.", data: FieldErrors = {}) =>
|
|
30
|
+
new ApiError(400, message, data);
|
|
31
|
+
export const unauthorized = (message = "The request requires valid record authorization token.") => new ApiError(401, message);
|
|
32
|
+
export const forbidden = (message = "You are not allowed to perform this request.") => new ApiError(403, message);
|
|
33
|
+
export const notFound = (message = "The requested resource wasn't found.") => new ApiError(404, message);
|
|
34
|
+
export const internal = (message = "Something went wrong while processing your request.") => new ApiError(500, message);
|
|
35
|
+
|
|
36
|
+
export const validationFailed = (data: FieldErrors) =>
|
|
37
|
+
badRequest("An error occurred while validating the submitted data.", data);
|
|
38
|
+
|
|
39
|
+
export const V = {
|
|
40
|
+
required: { code: "validation_required", message: "Cannot be blank." },
|
|
41
|
+
length: (min: number, max: number) => ({
|
|
42
|
+
code: "validation_length_out_of_range",
|
|
43
|
+
message: `The length must be between ${min} and ${max}.`,
|
|
44
|
+
}),
|
|
45
|
+
invalidFormat: { code: "validation_invalid_format", message: "Invalid format." },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Response.json() picks a runtime-specific default content type (workerd: application/json, Bun: with charset);
|
|
49
|
+
// PocketBase answers with a bare application/json, so set it explicitly.
|
|
50
|
+
export function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
51
|
+
const headers = new Headers(init.headers); headers.set("content-type", "application/json");
|
|
52
|
+
return new Response(JSON.stringify(body), { ...init, headers });
|
|
53
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// File tokens (apis/file.go fileToken): short-lived JWTs signed with the collection's fileToken secret that
|
|
2
|
+
// let a client fetch protected files with ?token=... The files route uses `protectedAccess` for the check.
|
|
3
|
+
import type { Context, Hono } from "hono";
|
|
4
|
+
import { findAuthRecordByToken } from "./auth";
|
|
5
|
+
import { ipInList, realIP } from "./hardening";
|
|
6
|
+
import type { Collection } from "./collections/model";
|
|
7
|
+
import { unauthorized } from "./errors";
|
|
8
|
+
import { HookRecord } from "./hooks/record";
|
|
9
|
+
import { trigger } from "./hooks/runtime";
|
|
10
|
+
import { signJWT } from "./jwt";
|
|
11
|
+
import { recordMatchesRule, type RecordContext } from "./records/service";
|
|
12
|
+
import { rowToValues } from "./records/values";
|
|
13
|
+
import { loadSettings } from "./settings";
|
|
14
|
+
import type { AppEnv, AuthRecord, Row } from "./types";
|
|
15
|
+
|
|
16
|
+
const tokenOption = (c: Collection) => ((c.options as Record<string, unknown>).fileToken ?? {}) as { secret?: string; duration?: number };
|
|
17
|
+
|
|
18
|
+
export async function newFileToken(auth: AuthRecord): Promise<string> {
|
|
19
|
+
const opt = tokenOption(auth.collection);
|
|
20
|
+
const key = String(auth.row.tokenKey ?? "") + String(opt.secret ?? "");
|
|
21
|
+
if (!key) throw new Error("missing or invalid signing key");
|
|
22
|
+
return signJWT({ type: "file", id: String(auth.row.id), collectionId: auth.collection.id }, key, Number(opt.duration ?? 0) || 180);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function mountFilesApi(app: Hono<AppEnv>) {
|
|
26
|
+
app.post("/api/files/token", async (c) => {
|
|
27
|
+
const auth = c.get("auth");
|
|
28
|
+
if (!auth) throw unauthorized("The request requires valid record authorization token.");
|
|
29
|
+
const token = await newFileToken(auth);
|
|
30
|
+
const ev = { app: undefined as unknown, token, record: HookRecord.fromRow(auth.collection, auth.row), next: async () => undefined as unknown };
|
|
31
|
+
let res: Response | null = null;
|
|
32
|
+
await trigger("onFileTokenRequest", ev, auth.collection.name, async () => { res = c.json({ token: ev.token }); });
|
|
33
|
+
return res ?? c.json({ token });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// protected file: the ?token= file token (superusers subject to the IP allowlist) must satisfy the view rule
|
|
38
|
+
export async function protectedAccess(c: Context<AppEnv>, ctx: RecordContext, collection: Collection, row: Row): Promise<boolean> {
|
|
39
|
+
const token = c.req.query("token") ?? "";
|
|
40
|
+
let auth = token ? await findAuthRecordByToken(c.env.DB, token, "file") : null;
|
|
41
|
+
if (auth && auth.collection.name === "_superusers") {
|
|
42
|
+
const allowed = (await loadSettings(c.env.DB)).superuserIPs;
|
|
43
|
+
if (allowed.length && !ipInList(allowed, await realIP(c))) auth = null;
|
|
44
|
+
}
|
|
45
|
+
const superuser = !!auth && auth.collection.name === "_superusers";
|
|
46
|
+
const viewRule = collection.viewRule;
|
|
47
|
+
if (superuser) return true;
|
|
48
|
+
if (viewRule === null) return false;
|
|
49
|
+
const fctx: RecordContext = { ...ctx, auth, superuser, request: { ...ctx.request, auth: auth ? { collection: auth.collection, row: auth.row } : null, context: "protectedFile" } };
|
|
50
|
+
if (viewRule.trim() === "") return true;
|
|
51
|
+
return recordMatchesRule(fctx, collection, viewRule, rowToValues(collection, row));
|
|
52
|
+
}
|