@protobase/server 0.7.0 → 0.8.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/dist/assistant/conversations.d.ts +1 -1
- package/dist/assistant/tools.d.ts +1 -1
- package/dist/assistant/turn.d.ts +2 -2
- package/dist/audit/origin.js +12 -0
- package/dist/auth/jwt.d.ts +2 -0
- package/dist/auth/jwt.js +5 -3
- package/dist/batch/reorder.js +1 -1
- package/dist/better-auth/authenticator.d.ts +7 -3
- package/dist/better-auth/authenticator.js +24 -3
- package/dist/better-auth/create-auth.d.ts +619 -81
- package/dist/better-auth/create-auth.js +64 -27
- package/dist/better-auth/organizations/accept-invitation.js +80 -0
- package/dist/better-auth/organizations/active-organization.js +12 -0
- package/dist/better-auth/organizations/admin-routes.js +83 -0
- package/dist/better-auth/organizations/app-roles.js +43 -0
- package/dist/better-auth/organizations/host.d.ts +53 -0
- package/dist/better-auth/organizations/host.js +251 -0
- package/dist/better-auth/organizations/invitation-endpoints.js +133 -0
- package/dist/better-auth/organizations/invitation-mail.js +17 -0
- package/dist/better-auth/organizations/invitation-token.js +27 -0
- package/dist/better-auth/organizations/member-endpoints.js +182 -0
- package/dist/better-auth/organizations/options.d.ts +48 -0
- package/dist/better-auth/organizations/options.js +23 -0
- package/dist/better-auth/organizations/organization-plugin.js +128 -0
- package/dist/better-auth/organizations/protobase-plugin.js +21 -0
- package/dist/better-auth/organizations/role-definitions.d.ts +15 -0
- package/dist/better-auth/organizations/role-definitions.js +56 -0
- package/dist/better-auth/organizations/session-organization.js +68 -0
- package/dist/better-auth/organizations/store.d.ts +19 -0
- package/dist/better-auth/organizations/store.js +73 -0
- package/dist/better-auth/staff-checks.js +5 -1
- package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
- package/dist/better-auth/status-route.js +14 -3
- package/dist/better-auth/users.d.ts +2 -1
- package/dist/better-auth/users.js +4 -1
- package/dist/create-admin.d.ts +8 -2
- package/dist/create-admin.js +31 -2
- package/dist/deps.d.ts +1 -0
- package/dist/files/attach.js +80 -0
- package/dist/files/cleanup-runner.d.ts +8 -0
- package/dist/files/cleanup-runner.js +58 -0
- package/dist/files/cleanup-schedule.d.ts +28 -0
- package/dist/files/cleanup-schedule.js +48 -0
- package/dist/files/detect.js +51 -0
- package/dist/files/download-url.js +43 -0
- package/dist/files/download.js +90 -0
- package/dist/files/duration.js +35 -0
- package/dist/files/file-cleanup.d.ts +18 -0
- package/dist/files/file-cleanup.js +17 -0
- package/dist/files/local-files.d.ts +17 -0
- package/dist/files/local-files.js +101 -0
- package/dist/files/media-types.js +99 -0
- package/dist/files/options.d.ts +15 -0
- package/dist/files/present.d.ts +15 -0
- package/dist/files/present.js +59 -0
- package/dist/files/processors.d.ts +15 -0
- package/dist/files/processors.js +39 -0
- package/dist/files/providers.js +70 -0
- package/dist/files/routes.js +26 -0
- package/dist/files/runtime.d.ts +3 -0
- package/dist/files/runtime.js +37 -0
- package/dist/files/signer.js +32 -0
- package/dist/files/store.d.ts +37 -0
- package/dist/files/ticket.js +87 -0
- package/dist/files/upload-body.js +85 -0
- package/dist/files/upload.js +124 -0
- package/dist/files/uri.js +47 -0
- package/dist/functions/define-function.d.ts +71 -0
- package/dist/functions/define-function.js +36 -0
- package/dist/functions/function-app.d.ts +17 -0
- package/dist/functions/function-app.js +19 -0
- package/dist/functions/read-json.d.ts +35 -0
- package/dist/functions/read-json.js +24 -0
- package/dist/functions/records.d.ts +43 -0
- package/dist/functions/records.js +31 -0
- package/dist/functions/require-env.d.ts +7 -0
- package/dist/functions/require-env.js +12 -0
- package/dist/functions/routes.js +60 -0
- package/dist/functions/verify-signature.d.ts +32 -0
- package/dist/functions/verify-signature.js +49 -0
- package/dist/index.d.ts +21 -3
- package/dist/index.js +13 -3
- package/dist/list-service.js +4 -2
- package/dist/openapi/resource-paths.js +78 -1
- package/dist/openapi/schemas.js +28 -3
- package/dist/project-config.d.ts +7 -1
- package/dist/project-config.js +37 -1
- package/dist/read-service.js +5 -1
- package/dist/request-access.d.ts +5 -0
- package/dist/request-access.js +9 -4
- package/dist/resource-source.d.ts +4 -1
- package/dist/routes/batch.js +1 -1
- package/dist/routes/collection.js +3 -1
- package/dist/routes/record.js +2 -5
- package/dist/types.d.ts +25 -5
- package/dist/write-pipeline.js +2 -1
- package/dist/write-shared.js +6 -3
- package/dist/write-update.js +2 -1
- package/package.json +6 -4
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { badRequest } from "../problem.js";
|
|
2
|
+
import { baseOf, parseUri } from "./uri.js";
|
|
3
|
+
import { tenantScope } from "../tenant.js";
|
|
4
|
+
import { readTicket } from "./ticket.js";
|
|
5
|
+
//#region src/files/attach.ts
|
|
6
|
+
/** The ticket scope of a caller's upload for one field. */
|
|
7
|
+
const ticketScope = (access, field) => ({
|
|
8
|
+
tenant: String(tenantScope(access.entry, access.session)?.tenantValue ?? ""),
|
|
9
|
+
resource: access.entry.name,
|
|
10
|
+
field,
|
|
11
|
+
user: String(access.session.user.id)
|
|
12
|
+
});
|
|
13
|
+
const fileFields = (access) => Object.values(access.full.fields).filter((field) => field.file);
|
|
14
|
+
/**
|
|
15
|
+
* File values of a validated create or update body, made ready to store: a ticket from `:upload` becomes its clean URI
|
|
16
|
+
* plus the derived fields it carries, `null` clears the field and its derived fields, and the stored value itself is a
|
|
17
|
+
* no-op. Anything else is refused, so a row can only point at a file its writer uploaded for that field.
|
|
18
|
+
*/
|
|
19
|
+
const attachFiles = async (access, values, current) => {
|
|
20
|
+
const names = Object.keys(values).filter((name) => access.full.fields[name]?.file);
|
|
21
|
+
const files = access.deps.files;
|
|
22
|
+
if (names.length === 0 || !files) return values;
|
|
23
|
+
const result = { ...values };
|
|
24
|
+
const errors = [];
|
|
25
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
const field = access.full.fields[name];
|
|
28
|
+
const derive = field.file.derive ?? [];
|
|
29
|
+
const value = values[name];
|
|
30
|
+
if (value === null) {
|
|
31
|
+
for (const target of derive) result[target] = null;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (current && value === current[name]) {
|
|
35
|
+
delete result[name];
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const ticket = await readTicket(files.signer, String(value), ticketScope(access, name), now);
|
|
39
|
+
if (!ticket.ok || ticket.ref.provider !== field.file.provider) {
|
|
40
|
+
const expired = !ticket.ok && ticket.reason === "expired";
|
|
41
|
+
errors.push({
|
|
42
|
+
field: name,
|
|
43
|
+
code: "invalid-value",
|
|
44
|
+
message: expired ? "The upload expired; upload the file again" : "Not a file uploaded for this field; upload it with :upload first"
|
|
45
|
+
});
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
result[name] = ticket.uri;
|
|
49
|
+
for (const target of derive) result[target] = Object.hasOwn(ticket.derived, target) ? ticket.derived[target] : null;
|
|
50
|
+
}
|
|
51
|
+
if (errors.length > 0) throw badRequest("invalid-record", `The request body is invalid: ${errors[0].field}: ${errors[0].message}`, { errors });
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Schedules the delete of every file a write took out of its row: a replaced or cleared value, or all of a hard-deleted
|
|
56
|
+
* row's files, each due after its provider's retention. It runs inside the write transaction, before the commit: if
|
|
57
|
+
* the write rolls back, the file is still referenced when the entry is due, and stays.
|
|
58
|
+
*/
|
|
59
|
+
const scheduleRemovedFiles = async (access, operation, before, after) => {
|
|
60
|
+
const files = access.deps.files;
|
|
61
|
+
if (!files || !before || operation === "create" || operation === "undelete") return;
|
|
62
|
+
if (operation === "delete" && access.full.softDelete) return;
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const removed = fileFields(access).flatMap((field) => {
|
|
65
|
+
const old = before[field.name];
|
|
66
|
+
if (typeof old !== "string") return [];
|
|
67
|
+
const next = after?.[field.name];
|
|
68
|
+
if (typeof next === "string" && baseOf(next) === baseOf(old)) return [];
|
|
69
|
+
const ref = parseUri(old);
|
|
70
|
+
if (!ref || !files.providers[ref.provider]) return [];
|
|
71
|
+
return [{
|
|
72
|
+
provider: ref.provider,
|
|
73
|
+
path: ref.path,
|
|
74
|
+
due: new Date(now + files.retention(ref.provider))
|
|
75
|
+
}];
|
|
76
|
+
});
|
|
77
|
+
if (removed.length > 0) await files.schedule.add(removed);
|
|
78
|
+
};
|
|
79
|
+
//#endregion
|
|
80
|
+
export { attachFiles, scheduleRemovedFiles, ticketScope };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/files/cleanup-runner.ts
|
|
3
|
+
/** Every declared file column, once per table and column even when two resources share them. */
|
|
4
|
+
const fileColumns = (models) => {
|
|
5
|
+
const seen = /* @__PURE__ */ new Map();
|
|
6
|
+
for (const model of models) for (const field of Object.values(model.fields)) {
|
|
7
|
+
if (field.type !== "file") continue;
|
|
8
|
+
seen.set(JSON.stringify([
|
|
9
|
+
model.table.schema ?? "",
|
|
10
|
+
model.table.name,
|
|
11
|
+
field.column
|
|
12
|
+
]), {
|
|
13
|
+
table: model.table,
|
|
14
|
+
column: field.column
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return [...seen.values()];
|
|
18
|
+
};
|
|
19
|
+
const chunk = (items, size) => Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, (i + 1) * size));
|
|
20
|
+
/** The identities (`provider:path`) among `uris` that some row still holds. */
|
|
21
|
+
const referenced = async (db, columns, uris) => {
|
|
22
|
+
const found = /* @__PURE__ */ new Set();
|
|
23
|
+
for (const { table, column } of columns) {
|
|
24
|
+
const value = sql`split_part(${sql.id(column)}, '?', 1)`;
|
|
25
|
+
const name = table.schema ? sql.id(table.schema, table.name) : sql.id(table.name);
|
|
26
|
+
const rows = await sql`select distinct ${value} as uri from ${name} where ${value} = any(${uris})`.execute(db);
|
|
27
|
+
for (const row of rows.rows) found.add(row.uri);
|
|
28
|
+
}
|
|
29
|
+
return found;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Handles every scheduled delete that is due: right before deleting, it checks that no declared file column holds the
|
|
33
|
+
* file; one that is referenced again (a rolled-back write, a restore, a shared value) is kept and its entry dropped.
|
|
34
|
+
* Uploads never attached are deleted here too, after their ticket expired. Running it twice at once is harmless.
|
|
35
|
+
*/
|
|
36
|
+
const runCleanup = async ({ db, columns, providers, schedule, now = /* @__PURE__ */ new Date() }) => {
|
|
37
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
38
|
+
const kept = /* @__PURE__ */ new Set();
|
|
39
|
+
for (const entries of chunk(await schedule.due(now), 500)) {
|
|
40
|
+
const uris = [...new Set(entries.map((entry) => `${entry.provider}:${entry.path}`))];
|
|
41
|
+
const inUse = await referenced(db, columns, uris);
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const uri = `${entry.provider}:${entry.path}`;
|
|
44
|
+
if (inUse.has(uri)) kept.add(uri);
|
|
45
|
+
else if (!deleted.has(uri)) {
|
|
46
|
+
await providers[entry.provider].store.delete(entry.path);
|
|
47
|
+
deleted.add(uri);
|
|
48
|
+
}
|
|
49
|
+
await schedule.done(entry);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
deleted: [...deleted],
|
|
54
|
+
kept: [...kept]
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
//#endregion
|
|
58
|
+
export { fileColumns, runCleanup };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { FileProvider } from "./store.js";
|
|
2
|
+
//#region src/files/cleanup-schedule.d.ts
|
|
3
|
+
/** A file to delete once `due`, unless a row references it again by then. */
|
|
4
|
+
export type ScheduledDelete = {
|
|
5
|
+
provider: string;
|
|
6
|
+
path: string;
|
|
7
|
+
due: Date;
|
|
8
|
+
};
|
|
9
|
+
export type CleanupEntry = ScheduledDelete & {
|
|
10
|
+
id: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Where scheduled deletes wait. The default keeps them in each provider's own storage (`storageCleanupSchedule`); a
|
|
14
|
+
* table in a schema of its own, written by a migration at update time, can take its place behind the same three calls.
|
|
15
|
+
*/
|
|
16
|
+
export type CleanupSchedule = {
|
|
17
|
+
add(deletes: ScheduledDelete[]): Promise<void>;
|
|
18
|
+
/** Every entry due at `now`. */
|
|
19
|
+
due(now: Date): Promise<CleanupEntry[]>;
|
|
20
|
+
/** Drops an entry once it is handled. */
|
|
21
|
+
done(entry: CleanupEntry): Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Scheduled deletes as a to-do folder in each provider's storage: one JSON object per delete in `.cleanup/`, named after
|
|
25
|
+
* its due time (`.cleanup/2026-10-12T03-00-00Z-<uuid>.json`) and saying which provider and path to delete.
|
|
26
|
+
*/
|
|
27
|
+
export declare const storageCleanupSchedule: (providers: Record<string, FileProvider>) => CleanupSchedule;
|
|
28
|
+
//#endregion
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//#region src/files/cleanup-schedule.ts
|
|
2
|
+
const cleanupFolder = ".cleanup/";
|
|
3
|
+
const stamp = (date) => date.toISOString().slice(0, 19).replaceAll(":", "-") + "Z";
|
|
4
|
+
const entryPattern = /^\.cleanup\/(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)-[0-9a-f-]{36}\.json$/;
|
|
5
|
+
const isScheduledDelete = (value) => typeof value === "object" && value !== null && typeof value.provider === "string" && typeof value.path === "string" && typeof value.due === "string";
|
|
6
|
+
/**
|
|
7
|
+
* Scheduled deletes as a to-do folder in each provider's storage: one JSON object per delete in `.cleanup/`, named after
|
|
8
|
+
* its due time (`.cleanup/2026-10-12T03-00-00Z-<uuid>.json`) and saying which provider and path to delete.
|
|
9
|
+
*/
|
|
10
|
+
const storageCleanupSchedule = (providers) => {
|
|
11
|
+
const storeOf = (provider) => {
|
|
12
|
+
const found = providers[provider];
|
|
13
|
+
if (!found) throw new Error(`No file provider "${provider}" is configured`);
|
|
14
|
+
return found.store;
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
add: async (deletes) => {
|
|
18
|
+
for (const { provider, path, due } of deletes) {
|
|
19
|
+
const body = new Blob([JSON.stringify({
|
|
20
|
+
provider,
|
|
21
|
+
path,
|
|
22
|
+
due: due.toISOString()
|
|
23
|
+
})]).stream();
|
|
24
|
+
await storeOf(provider).put(`${cleanupFolder}${stamp(due)}-${crypto.randomUUID()}.json`, body);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
due: async (now) => {
|
|
28
|
+
const entries = [];
|
|
29
|
+
for (const [provider, { store }] of Object.entries(providers)) for (const object of await store.list(cleanupFolder)) {
|
|
30
|
+
const match = entryPattern.exec(object.path);
|
|
31
|
+
if (!match) throw new Error(`"${object.path}" in file provider "${provider}" is not a scheduled delete; remove it`);
|
|
32
|
+
if (match[1] > stamp(now)) continue;
|
|
33
|
+
const content = JSON.parse(await new Response(await store.read(object.path)).text());
|
|
34
|
+
if (!isScheduledDelete(content) || content.provider !== provider) throw new Error(`"${object.path}" in file provider "${provider}" is not a scheduled delete of it; remove it`);
|
|
35
|
+
entries.push({
|
|
36
|
+
id: object.path,
|
|
37
|
+
provider,
|
|
38
|
+
path: content.path,
|
|
39
|
+
due: new Date(content.due)
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return entries;
|
|
43
|
+
},
|
|
44
|
+
done: (entry) => storeOf(entry.provider).delete(entry.id)
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { cleanupFolder, storageCleanupSchedule };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { canonicalExtension, isKnownType, octetStream, textTypes, typeOfExtension } from "./media-types.js";
|
|
2
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
3
|
+
//#region src/files/detect.ts
|
|
4
|
+
const isText = (head, complete) => {
|
|
5
|
+
if (head.includes(0)) return false;
|
|
6
|
+
try {
|
|
7
|
+
new TextDecoder("utf-8", { fatal: true }).decode(head, { stream: !complete });
|
|
8
|
+
return true;
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error instanceof TypeError) return false;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const declaredType = (declared) => {
|
|
15
|
+
const type = declared?.split(";", 1)[0].trim().toLowerCase();
|
|
16
|
+
return type && type !== "application/octet-stream" ? type : void 0;
|
|
17
|
+
};
|
|
18
|
+
const decided = (type, hints) => {
|
|
19
|
+
const from = hints.find((hint) => hint !== void 0 && hint !== type);
|
|
20
|
+
return {
|
|
21
|
+
type,
|
|
22
|
+
extension: canonicalExtension(type),
|
|
23
|
+
...from && { corrected: {
|
|
24
|
+
from,
|
|
25
|
+
to: type
|
|
26
|
+
} }
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The type of an upload, decided once from three signals: the content's magic bytes (C), the name's extension (E) and the
|
|
31
|
+
* client's declared type (H). A recognized C always wins. Unrecognized text takes E when it is a text type, H when there
|
|
32
|
+
* is no extension, otherwise text/plain. Unrecognized binary takes E only when the field lists it exactly and H does not
|
|
33
|
+
* disagree; anything else is application/octet-stream. H never decides on its own.
|
|
34
|
+
*/
|
|
35
|
+
const decideType = async ({ head, complete, name, declared, accept }) => {
|
|
36
|
+
const fromName = name === void 0 ? void 0 : typeOfExtension(name);
|
|
37
|
+
const hasExtension = name !== void 0 && /\.[^.]+$/.test(name);
|
|
38
|
+
const header = declaredType(declared);
|
|
39
|
+
const hints = [fromName, header];
|
|
40
|
+
const detected = await fileTypeFromBuffer(head);
|
|
41
|
+
if (detected) return decided(isKnownType(detected.mime) ? detected.mime : octetStream, hints);
|
|
42
|
+
if (isText(head, complete)) {
|
|
43
|
+
if (fromName && textTypes.has(fromName)) return decided(fromName, hints);
|
|
44
|
+
if (!hasExtension && header && textTypes.has(header)) return decided(header, hints);
|
|
45
|
+
return decided("text/plain", hints);
|
|
46
|
+
}
|
|
47
|
+
if (fromName !== void 0 && fromName !== "application/octet-stream" && accept.includes(fromName) && (header === void 0 || header === fromName)) return decided(fromName, hints);
|
|
48
|
+
return decided(octetStream, hints);
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { decideType };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/files/download-url.ts
|
|
2
|
+
/** Download URLs stay valid one to two hours and repeat within the hour, so browsers can cache them. */
|
|
3
|
+
const downloadExpiry = (now = Date.now()) => (Math.ceil(now / 36e5) + 1) * 3600;
|
|
4
|
+
const parts = (grant) => [
|
|
5
|
+
grant.provider,
|
|
6
|
+
grant.path,
|
|
7
|
+
grant.resource,
|
|
8
|
+
grant.key,
|
|
9
|
+
grant.field,
|
|
10
|
+
grant.expires
|
|
11
|
+
];
|
|
12
|
+
/** `<base>/<provider>/<path>?r=<resource>&k=<key>&f=<field>&exp=<seconds>&sig=<signature>` */
|
|
13
|
+
const signedDownloadUrl = async (signer, base, grant) => {
|
|
14
|
+
const query = new URLSearchParams({
|
|
15
|
+
r: grant.resource,
|
|
16
|
+
k: grant.key,
|
|
17
|
+
f: grant.field,
|
|
18
|
+
exp: String(grant.expires),
|
|
19
|
+
sig: await signer.sign("download", parts(grant))
|
|
20
|
+
});
|
|
21
|
+
return `${base}/${grant.provider}/${grant.path}?${query}`;
|
|
22
|
+
};
|
|
23
|
+
/** The grant of a download URL whose signature checks out; `expired` once it is past its time. */
|
|
24
|
+
const readDownloadUrl = async (signer, provider, path, query, now = Date.now()) => {
|
|
25
|
+
const expires = Number(query.get("exp"));
|
|
26
|
+
const grant = {
|
|
27
|
+
provider,
|
|
28
|
+
path,
|
|
29
|
+
resource: query.get("r") ?? "",
|
|
30
|
+
key: query.get("k") ?? "",
|
|
31
|
+
field: query.get("f") ?? "",
|
|
32
|
+
expires
|
|
33
|
+
};
|
|
34
|
+
const signature = query.get("sig") ?? "";
|
|
35
|
+
if (!Number.isSafeInteger(expires) || !await signer.verify("download", parts(grant), signature)) return { status: "invalid" };
|
|
36
|
+
if (expires * 1e3 < now) return { status: "expired" };
|
|
37
|
+
return {
|
|
38
|
+
status: "ok",
|
|
39
|
+
grant
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { downloadExpiry, readDownloadUrl, signedDownloadUrl };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { HttpProblem, forbidden, notFound } from "../problem.js";
|
|
2
|
+
import { decodeKeyText } from "../key.js";
|
|
3
|
+
import { readDownloadUrl } from "./download-url.js";
|
|
4
|
+
import { downloadName, isInline, servedType } from "./media-types.js";
|
|
5
|
+
import { baseOf, isFilePath, parseUri, tenantSegment } from "./uri.js";
|
|
6
|
+
import { fetchRecord } from "../records.js";
|
|
7
|
+
import { readTransaction } from "../transactions.js";
|
|
8
|
+
//#region src/files/download.ts
|
|
9
|
+
const gone = () => notFound("There is no such file");
|
|
10
|
+
const rangeOf = (header, size) => {
|
|
11
|
+
if (!header) return void 0;
|
|
12
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
13
|
+
if (!match || match[1] === "" && match[2] === "") return false;
|
|
14
|
+
const start = match[1] === "" ? Math.max(size - Number(match[2]), 0) : Number(match[1]);
|
|
15
|
+
const end = match[1] === "" || match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
|
|
16
|
+
return start <= end && start < size ? {
|
|
17
|
+
start,
|
|
18
|
+
end
|
|
19
|
+
} : false;
|
|
20
|
+
};
|
|
21
|
+
const disposition = (name, type) => {
|
|
22
|
+
const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
|
23
|
+
return `${isInline(type) ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
24
|
+
};
|
|
25
|
+
const grantedName = async (deps, provider, path, query) => {
|
|
26
|
+
const read = await readDownloadUrl(deps.files.signer, provider, path, query);
|
|
27
|
+
if (read.status === "invalid") throw forbidden("invalid-signature", "This file link is not valid");
|
|
28
|
+
if (read.status === "expired") throw forbidden("link-expired", "This file link expired; load the record again for a new one");
|
|
29
|
+
const { grant } = read;
|
|
30
|
+
const entry = deps.registry.find(grant.resource);
|
|
31
|
+
const field = entry?.model.fields[grant.field];
|
|
32
|
+
if (!entry || field?.type !== "file") throw gone();
|
|
33
|
+
const tenantField = entry.model.tenant ? entry.model.fields[entry.model.tenant] : void 0;
|
|
34
|
+
const fields = tenantField ? [field, tenantField] : [field];
|
|
35
|
+
const row = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchRecord(trx, entry, decodeKeyText(entry, grant.key), void 0, {
|
|
36
|
+
deleted: "show",
|
|
37
|
+
fields
|
|
38
|
+
}));
|
|
39
|
+
const value = row?.record[field.name];
|
|
40
|
+
if (typeof value !== "string" || baseOf(value) !== `${provider}:${path}`) throw gone();
|
|
41
|
+
if (tenantField && path.split("/")[0] !== await tenantSegment(row.record[tenantField.name])) throw gone();
|
|
42
|
+
return {
|
|
43
|
+
name: parseUri(value)?.name ?? path,
|
|
44
|
+
expires: grant.expires
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* `GET <systemPath>/files/<provider>/<path>`: a public provider's file for anyone; a private one's only through a signed
|
|
49
|
+
* URL whose row still holds it. The Content-Type comes from the path's extension, never from what was uploaded, and
|
|
50
|
+
* nothing renders: `nosniff`, a sandboxing CSP, and `inline` only for images, PDF, audio and video.
|
|
51
|
+
*/
|
|
52
|
+
const serveFile = async (deps, request, provider, path) => {
|
|
53
|
+
const configured = deps.files?.providers[provider];
|
|
54
|
+
if (!configured || !isFilePath(path)) throw gone();
|
|
55
|
+
const granted = configured.public ? void 0 : await grantedName(deps, provider, path, new URL(request.url).searchParams);
|
|
56
|
+
const object = await configured.store.head(path);
|
|
57
|
+
if (!object) throw gone();
|
|
58
|
+
const type = servedType(path);
|
|
59
|
+
const name = downloadName(granted?.name ?? path.slice(path.lastIndexOf("/") + 1), type);
|
|
60
|
+
const caching = granted ? `private, max-age=${Math.max(granted.expires - Math.floor(Date.now() / 1e3), 0)}` : "public, max-age=31536000, immutable";
|
|
61
|
+
const headers = {
|
|
62
|
+
"content-type": type,
|
|
63
|
+
"content-disposition": disposition(name, type),
|
|
64
|
+
"x-content-type-options": "nosniff",
|
|
65
|
+
"content-security-policy": "sandbox; default-src 'none'",
|
|
66
|
+
"cache-control": caching,
|
|
67
|
+
"accept-ranges": "bytes"
|
|
68
|
+
};
|
|
69
|
+
const range = rangeOf(request.headers.get("range"), object.size);
|
|
70
|
+
if (range === false) throw new HttpProblem(416, "range-not-satisfiable", "Range Not Satisfiable", "The requested range is outside the file", {}, { "content-range": `bytes */${object.size}` });
|
|
71
|
+
const body = await configured.store.read(path, range);
|
|
72
|
+
if (!body) throw gone();
|
|
73
|
+
if (!range) return new Response(body, {
|
|
74
|
+
status: 200,
|
|
75
|
+
headers: {
|
|
76
|
+
...headers,
|
|
77
|
+
"content-length": String(object.size)
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return new Response(body, {
|
|
81
|
+
status: 206,
|
|
82
|
+
headers: {
|
|
83
|
+
...headers,
|
|
84
|
+
"content-length": String(range.end - range.start + 1),
|
|
85
|
+
"content-range": `bytes ${range.start}-${range.end}/${object.size}`
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
//#endregion
|
|
90
|
+
export { serveFile };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/files/duration.ts
|
|
2
|
+
const units = {
|
|
3
|
+
s: 1e3,
|
|
4
|
+
second: 1e3,
|
|
5
|
+
m: 6e4,
|
|
6
|
+
min: 6e4,
|
|
7
|
+
minute: 6e4,
|
|
8
|
+
h: 36e5,
|
|
9
|
+
hour: 36e5,
|
|
10
|
+
d: 864e5,
|
|
11
|
+
day: 864e5,
|
|
12
|
+
w: 6048e5,
|
|
13
|
+
week: 6048e5
|
|
14
|
+
};
|
|
15
|
+
/** Milliseconds from a number of them or text such as `1 day`, `14d`, `12 hours` or `0`. */
|
|
16
|
+
const parseDuration = (value, label) => {
|
|
17
|
+
if (typeof value === "number") {
|
|
18
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a whole number of milliseconds, not ${value}`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
const invalid = /* @__PURE__ */ new Error(`${label} is "${value}"; write it like "1 day", "14d" or "12 hours"`);
|
|
22
|
+
const match = /^\s*(\d+)\s*([a-z]*)\s*$/i.exec(value);
|
|
23
|
+
if (!match) throw invalid;
|
|
24
|
+
const amount = Number(match[1]);
|
|
25
|
+
const word = match[2].toLowerCase();
|
|
26
|
+
if (word === "") {
|
|
27
|
+
if (amount === 0) return 0;
|
|
28
|
+
throw invalid;
|
|
29
|
+
}
|
|
30
|
+
const unit = units[word] ?? units[word.replace(/s$/, "")];
|
|
31
|
+
if (unit === void 0) throw invalid;
|
|
32
|
+
return amount * unit;
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { parseDuration };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { FilesOptions } from "./options.js";
|
|
2
|
+
import { CleanupReport } from "./cleanup-runner.js";
|
|
3
|
+
import { ResourceSource } from "../resource-source.js";
|
|
4
|
+
import { Db } from "@protobase/query";
|
|
5
|
+
//#region src/files/file-cleanup.d.ts
|
|
6
|
+
export type FileCleanupInput = {
|
|
7
|
+
resources: ResourceSource[];
|
|
8
|
+
db: Db;
|
|
9
|
+
files?: FilesOptions;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The scheduled deletes of a project's files, for a runner outside a request: `protobase files cleanup`, or an interval
|
|
13
|
+
* in `protobase serve`. `undefined` when the project has no file fields.
|
|
14
|
+
*/
|
|
15
|
+
export declare const createFileCleanup: ({ resources, db, files }: FileCleanupInput) => {
|
|
16
|
+
run: (now?: Date) => Promise<CleanupReport>;
|
|
17
|
+
} | undefined;
|
|
18
|
+
//#endregion
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cleanupFiles, createFilesRuntime } from "./runtime.js";
|
|
2
|
+
//#region src/files/file-cleanup.ts
|
|
3
|
+
/**
|
|
4
|
+
* The scheduled deletes of a project's files, for a runner outside a request: `protobase files cleanup`, or an interval
|
|
5
|
+
* in `protobase serve`. `undefined` when the project has no file fields.
|
|
6
|
+
*/
|
|
7
|
+
const createFileCleanup = ({ resources, db, files }) => {
|
|
8
|
+
const runtime = createFilesRuntime({
|
|
9
|
+
...files && { options: files },
|
|
10
|
+
env: globalThis.process?.env ?? {},
|
|
11
|
+
models: resources.map((source) => source.toModel()),
|
|
12
|
+
systemPath: ""
|
|
13
|
+
});
|
|
14
|
+
return runtime && { run: (now) => cleanupFiles(runtime, db, now) };
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { createFileCleanup };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { FileProvider, FileStore } from "./store.js";
|
|
2
|
+
//#region src/files/local-files.d.ts
|
|
3
|
+
export type LocalFilesOptions = {
|
|
4
|
+
/** The folder the files live in, created when missing; relative to the working directory. */
|
|
5
|
+
dir: string;
|
|
6
|
+
/** Files anyone with the link may open, served without a signature. */
|
|
7
|
+
public?: boolean;
|
|
8
|
+
/** Where public files are linked from instead of the app's own `/api/files/<provider>`, such as a CDN in front of it. */
|
|
9
|
+
publicUrl?: string;
|
|
10
|
+
/** How long bytes outlive their last reference; default `files.retention`, or 0 for a public provider. */
|
|
11
|
+
retention?: number | string;
|
|
12
|
+
};
|
|
13
|
+
/** A store in a folder on local disk. Writes go to `.tmp/` first and are renamed into place, so they are atomic. */
|
|
14
|
+
export declare const localFileStore: (dir: string) => FileStore;
|
|
15
|
+
/** A provider on local disk: `localFiles({ dir: './data/files/private' })`. */
|
|
16
|
+
export declare const localFiles: ({ dir, public: isPublic, publicUrl, retention }: LocalFilesOptions) => FileProvider;
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { parseDuration } from "./duration.js";
|
|
3
|
+
//#region src/files/local-files.ts
|
|
4
|
+
const node = async () => ({
|
|
5
|
+
fs: await import("node:fs/promises"),
|
|
6
|
+
streams: await import("node:fs"),
|
|
7
|
+
path: await import("node:path"),
|
|
8
|
+
stream: await import("node:stream"),
|
|
9
|
+
pipeline: (await import("node:stream/promises")).pipeline,
|
|
10
|
+
crypto: await import("node:crypto")
|
|
11
|
+
});
|
|
12
|
+
const codeOf = (error) => error instanceof Error ? error.code : void 0;
|
|
13
|
+
const storageFull = () => new HttpProblem(507, "storage-full", "Insufficient Storage", "There is no room left to store this file");
|
|
14
|
+
/** A store in a folder on local disk. Writes go to `.tmp/` first and are renamed into place, so they are atomic. */
|
|
15
|
+
const localFileStore = (dir) => {
|
|
16
|
+
const resolve = async (relative) => {
|
|
17
|
+
const { path } = await node();
|
|
18
|
+
const root = path.resolve(dir);
|
|
19
|
+
const full = path.resolve(root, relative);
|
|
20
|
+
if (!full.startsWith(`${root}${path.sep}`)) throw new Error(`"${relative}" is outside the file store`);
|
|
21
|
+
return full;
|
|
22
|
+
};
|
|
23
|
+
const head = async (relative) => {
|
|
24
|
+
const { fs } = await node();
|
|
25
|
+
try {
|
|
26
|
+
const stat = await fs.stat(await resolve(relative));
|
|
27
|
+
return stat.isFile() ? {
|
|
28
|
+
path: relative,
|
|
29
|
+
size: stat.size,
|
|
30
|
+
modified: stat.mtime
|
|
31
|
+
} : void 0;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (codeOf(error) === "ENOENT" || codeOf(error) === "ENOTDIR") return void 0;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const put = async (relative, body) => {
|
|
38
|
+
const { fs, streams, path, stream, pipeline, crypto } = await node();
|
|
39
|
+
const full = await resolve(relative);
|
|
40
|
+
const temporary = await resolve(`.tmp/${crypto.randomUUID()}`);
|
|
41
|
+
await fs.mkdir(path.dirname(temporary), { recursive: true });
|
|
42
|
+
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
43
|
+
try {
|
|
44
|
+
await pipeline(stream.Readable.fromWeb(body), streams.createWriteStream(temporary, { flags: "wx" }));
|
|
45
|
+
await fs.rename(temporary, full);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await fs.rm(temporary, { force: true });
|
|
48
|
+
if (codeOf(error) === "ENOSPC" || codeOf(error) === "EDQUOT") throw storageFull();
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
return { size: (await fs.stat(full)).size };
|
|
52
|
+
};
|
|
53
|
+
const read = async (relative, range) => {
|
|
54
|
+
const { streams, stream } = await node();
|
|
55
|
+
if (!await head(relative)) return void 0;
|
|
56
|
+
const reader = streams.createReadStream(await resolve(relative), range ? {
|
|
57
|
+
start: range.start,
|
|
58
|
+
end: range.end
|
|
59
|
+
} : {});
|
|
60
|
+
return stream.Readable.toWeb(reader);
|
|
61
|
+
};
|
|
62
|
+
const remove = async (relative) => {
|
|
63
|
+
const { fs } = await node();
|
|
64
|
+
await fs.rm(await resolve(relative), { force: true });
|
|
65
|
+
};
|
|
66
|
+
const list = async (prefix) => {
|
|
67
|
+
const { fs, path } = await node();
|
|
68
|
+
const root = path.resolve(dir);
|
|
69
|
+
const folder = await resolve(prefix.replace(/\/$/, ""));
|
|
70
|
+
let names;
|
|
71
|
+
try {
|
|
72
|
+
names = await fs.readdir(folder, { recursive: true });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (codeOf(error) === "ENOENT") return [];
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const found = [];
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
const relative = path.relative(root, path.join(folder, name)).split(path.sep).join("/");
|
|
80
|
+
const object = await head(relative);
|
|
81
|
+
if (object) found.push(object);
|
|
82
|
+
}
|
|
83
|
+
return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
put,
|
|
87
|
+
head,
|
|
88
|
+
read,
|
|
89
|
+
delete: remove,
|
|
90
|
+
list
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
/** A provider on local disk: `localFiles({ dir: './data/files/private' })`. */
|
|
94
|
+
const localFiles = ({ dir, public: isPublic = false, publicUrl, retention }) => ({
|
|
95
|
+
store: localFileStore(dir),
|
|
96
|
+
public: isPublic || publicUrl !== void 0,
|
|
97
|
+
...publicUrl !== void 0 && { publicUrl: publicUrl.replace(/\/$/, "") },
|
|
98
|
+
...retention !== void 0 && { retention: parseDuration(retention, "retention") }
|
|
99
|
+
});
|
|
100
|
+
//#endregion
|
|
101
|
+
export { localFileStore, localFiles };
|