@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,99 @@
|
|
|
1
|
+
//#region src/files/media-types.ts
|
|
2
|
+
/**
|
|
3
|
+
* The one table between types and extensions. An upload's decided type picks its path's extension, and every download's
|
|
4
|
+
* Content-Type is looked up from that extension here, so what is served never comes from a client's header.
|
|
5
|
+
*/
|
|
6
|
+
const table = [
|
|
7
|
+
["image/png", ["png"]],
|
|
8
|
+
["image/jpeg", ["jpg", "jpeg"]],
|
|
9
|
+
["image/gif", ["gif"]],
|
|
10
|
+
["image/webp", ["webp"]],
|
|
11
|
+
["image/avif", ["avif"]],
|
|
12
|
+
["image/heic", ["heic"]],
|
|
13
|
+
["image/bmp", ["bmp"]],
|
|
14
|
+
["image/tiff", ["tif", "tiff"]],
|
|
15
|
+
["image/x-icon", ["ico"]],
|
|
16
|
+
["image/svg+xml", ["svg"]],
|
|
17
|
+
["application/pdf", ["pdf"]],
|
|
18
|
+
["application/zip", ["zip"]],
|
|
19
|
+
["application/gzip", ["gz"]],
|
|
20
|
+
["application/x-tar", ["tar"]],
|
|
21
|
+
["application/x-7z-compressed", ["7z"]],
|
|
22
|
+
["application/vnd.rar", ["rar"]],
|
|
23
|
+
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", ["docx"]],
|
|
24
|
+
["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ["xlsx"]],
|
|
25
|
+
["application/vnd.openxmlformats-officedocument.presentationml.presentation", ["pptx"]],
|
|
26
|
+
["application/vnd.oasis.opendocument.text", ["odt"]],
|
|
27
|
+
["application/vnd.oasis.opendocument.spreadsheet", ["ods"]],
|
|
28
|
+
["application/vnd.oasis.opendocument.presentation", ["odp"]],
|
|
29
|
+
["application/msword", ["doc"]],
|
|
30
|
+
["application/vnd.ms-excel", ["xls"]],
|
|
31
|
+
["application/vnd.ms-powerpoint", ["ppt"]],
|
|
32
|
+
["application/rtf", ["rtf"]],
|
|
33
|
+
["application/epub+zip", ["epub"]],
|
|
34
|
+
["application/x-msdownload", ["exe"]],
|
|
35
|
+
["application/x-sqlite3", ["sqlite"]],
|
|
36
|
+
["audio/mpeg", ["mp3"]],
|
|
37
|
+
["audio/wav", ["wav"]],
|
|
38
|
+
["audio/ogg", ["ogg"]],
|
|
39
|
+
["audio/mp4", ["m4a"]],
|
|
40
|
+
["audio/flac", ["flac"]],
|
|
41
|
+
["video/mp4", ["mp4"]],
|
|
42
|
+
["video/webm", ["webm"]],
|
|
43
|
+
["video/quicktime", ["mov"]],
|
|
44
|
+
["video/x-matroska", ["mkv"]],
|
|
45
|
+
["text/csv", ["csv"]],
|
|
46
|
+
["text/tab-separated-values", ["tsv"]],
|
|
47
|
+
["text/plain", ["txt"]],
|
|
48
|
+
["text/markdown", ["md"]],
|
|
49
|
+
["application/json", ["json"]],
|
|
50
|
+
["application/octet-stream", ["bin"]]
|
|
51
|
+
];
|
|
52
|
+
const octetStream = "application/octet-stream";
|
|
53
|
+
const byExtension = new Map(table.flatMap(([type, extensions]) => extensions.map((extension) => [extension, type])));
|
|
54
|
+
const byType = new Map(table.map(([type, extensions]) => [type, extensions[0]]));
|
|
55
|
+
/** Text formats a name's extension may give text content, which magic bytes cannot tell apart. */
|
|
56
|
+
const textTypes = /* @__PURE__ */ new Set([
|
|
57
|
+
"text/csv",
|
|
58
|
+
"text/tab-separated-values",
|
|
59
|
+
"text/plain",
|
|
60
|
+
"text/markdown",
|
|
61
|
+
"application/json"
|
|
62
|
+
]);
|
|
63
|
+
const inlineTypes = /* @__PURE__ */ new Set([
|
|
64
|
+
"image/png",
|
|
65
|
+
"image/jpeg",
|
|
66
|
+
"image/gif",
|
|
67
|
+
"image/webp",
|
|
68
|
+
"image/avif",
|
|
69
|
+
"image/bmp",
|
|
70
|
+
"application/pdf"
|
|
71
|
+
]);
|
|
72
|
+
const extensionOf = (name) => {
|
|
73
|
+
const dot = name.lastIndexOf(".");
|
|
74
|
+
return dot <= 0 || dot === name.length - 1 ? void 0 : name.slice(dot + 1).toLowerCase();
|
|
75
|
+
};
|
|
76
|
+
/** The type of a name or path, from its extension; `undefined` for an extension the table does not know. */
|
|
77
|
+
const typeOfExtension = (name) => {
|
|
78
|
+
const extension = extensionOf(name);
|
|
79
|
+
return extension === void 0 ? void 0 : byExtension.get(extension);
|
|
80
|
+
};
|
|
81
|
+
/** The type a stored path is served as. */
|
|
82
|
+
const servedType = (path) => typeOfExtension(path) ?? "application/octet-stream";
|
|
83
|
+
/** The canonical extension of a type the table knows. */
|
|
84
|
+
const canonicalExtension = (type) => byType.get(type);
|
|
85
|
+
const isKnownType = (type) => byType.has(type);
|
|
86
|
+
const isInline = (type) => inlineTypes.has(type) || type.startsWith("audio/") || type.startsWith("video/");
|
|
87
|
+
/** Whether `type` passes a field's allowed list: exact types, `type/*` wildcards, `*\/*`; an empty list allows any. */
|
|
88
|
+
const accepts = (allowed, type) => allowed.length === 0 || allowed.some((entry) => entry === "*/*" || entry === type || entry.endsWith("/*") && type.startsWith(entry.slice(0, -1)));
|
|
89
|
+
/** The uploader's name with its extension made to match the stored type: `photo.png` holding a JPEG downloads as `photo.jpg`. */
|
|
90
|
+
const downloadName = (name, type) => {
|
|
91
|
+
const canonical = canonicalExtension(type);
|
|
92
|
+
if (!canonical || type === "application/octet-stream") return name;
|
|
93
|
+
const known = typeOfExtension(name);
|
|
94
|
+
if (known === type) return name;
|
|
95
|
+
const extension = extensionOf(name);
|
|
96
|
+
return `${extension !== void 0 && known !== void 0 ? name.slice(0, -extension.length - 1) : name}.${canonical}`;
|
|
97
|
+
};
|
|
98
|
+
//#endregion
|
|
99
|
+
export { accepts, canonicalExtension, downloadName, extensionOf, isInline, isKnownType, octetStream, servedType, textTypes, typeOfExtension };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FileProvider } from "./store.js";
|
|
2
|
+
//#region src/files/options.d.ts
|
|
3
|
+
/** `files` in `protobase.config.ts`. Environment variables override it (see `resolveProviders`). */
|
|
4
|
+
export type FilesOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* Providers by name, such as `private: localFiles({ dir: './data/files' })`. A name is part of every stored value, so
|
|
7
|
+
* name the role (`archive`), not the vendor. Without config, `private` and `public` are folders under `data/files`.
|
|
8
|
+
*/
|
|
9
|
+
providers?: Record<string, FileProvider>;
|
|
10
|
+
/** How long bytes outlive their last reference, for restores; default `1 day`. Public providers default to 0. */
|
|
11
|
+
retention?: number | string;
|
|
12
|
+
/** Signs upload tickets and download URLs; default `PROTOBASE_FILES_SECRET`, then `BETTER_AUTH_SECRET`. */
|
|
13
|
+
secret?: string;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import "../types.js";
|
|
2
|
+
import "../request-access.js";
|
|
3
|
+
//#region src/files/present.d.ts
|
|
4
|
+
/** A file field as responses carry it; `missing` when the value does not parse or names a provider the config lacks. */
|
|
5
|
+
export type FileObject = {
|
|
6
|
+
uri: string;
|
|
7
|
+
name: string;
|
|
8
|
+
type: string;
|
|
9
|
+
size?: number;
|
|
10
|
+
url?: string;
|
|
11
|
+
} | {
|
|
12
|
+
uri: string;
|
|
13
|
+
missing: true;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { downloadExpiry, signedDownloadUrl } from "./download-url.js";
|
|
2
|
+
import { servedType } from "./media-types.js";
|
|
3
|
+
import { parseUri } from "./uri.js";
|
|
4
|
+
import { encodeKey } from "@protobase/schema";
|
|
5
|
+
//#region src/files/present.ts
|
|
6
|
+
const fileObject = async (files, access, row, field, value, expires) => {
|
|
7
|
+
const ref = parseUri(value);
|
|
8
|
+
const provider = ref && files.providers[ref.provider];
|
|
9
|
+
if (!ref || !provider) return {
|
|
10
|
+
uri: value,
|
|
11
|
+
missing: true
|
|
12
|
+
};
|
|
13
|
+
const described = {
|
|
14
|
+
uri: value,
|
|
15
|
+
name: ref.name,
|
|
16
|
+
type: servedType(ref.path),
|
|
17
|
+
...ref.size !== void 0 && { size: ref.size }
|
|
18
|
+
};
|
|
19
|
+
if (provider.public) return {
|
|
20
|
+
...described,
|
|
21
|
+
url: `${provider.publicUrl ?? `${files.path}/${ref.provider}`}/${ref.path}`
|
|
22
|
+
};
|
|
23
|
+
const key = access.full.primaryKey.map((name) => row[name]);
|
|
24
|
+
if (key.some((part) => typeof part !== "string" && typeof part !== "number")) return described;
|
|
25
|
+
const keyText = encodeKey(key);
|
|
26
|
+
const url = await signedDownloadUrl(files.signer, files.path, {
|
|
27
|
+
provider: ref.provider,
|
|
28
|
+
path: ref.path,
|
|
29
|
+
resource: access.entry.name,
|
|
30
|
+
key: keyText,
|
|
31
|
+
field,
|
|
32
|
+
expires
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
...described,
|
|
36
|
+
url
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Records as responses carry them: each readable file field as `{ uri, name, type, size, url }`. The URL is minted here,
|
|
41
|
+
* after the ETag, for a caller who may already see the row: private files get a signed link to this row's field, public
|
|
42
|
+
* ones their permanent address. Stored values, hooks and ETags only ever see the URI.
|
|
43
|
+
*/
|
|
44
|
+
const presentFiles = async (access, rows) => {
|
|
45
|
+
const files = access.deps.files;
|
|
46
|
+
const fields = Object.values(access.model.fields).filter((field) => field.type === "file");
|
|
47
|
+
if (!files || fields.length === 0) return rows;
|
|
48
|
+
const expires = downloadExpiry();
|
|
49
|
+
return Promise.all(rows.map(async (row) => {
|
|
50
|
+
const shown = { ...row };
|
|
51
|
+
for (const field of fields) {
|
|
52
|
+
const value = row[field.name];
|
|
53
|
+
if (typeof value === "string") shown[field.name] = await fileObject(files, access, row, field.name, value, expires);
|
|
54
|
+
}
|
|
55
|
+
return shown;
|
|
56
|
+
}));
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { presentFiles };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FileProcessor } from "@protobase/schema";
|
|
2
|
+
//#region src/files/processors.d.ts
|
|
3
|
+
/** A processor of the project's own: `processor({ accepts: ['image/*'], run: async (file) => … })`. */
|
|
4
|
+
export declare const processor: (definition: FileProcessor) => FileProcessor;
|
|
5
|
+
/** An image's width or height in pixels, as shown (EXIF rotation applied), for an integer field. Null for other files. */
|
|
6
|
+
export declare const imageSize: (dimension: "width" | "height") => FileProcessor;
|
|
7
|
+
/** An image's width divided by its height, as decimal text with `decimals` places (default 4), for a decimal field. */
|
|
8
|
+
export declare const aspectRatio: ({ decimals }?: {
|
|
9
|
+
decimals?: number;
|
|
10
|
+
}) => FileProcessor;
|
|
11
|
+
/** The file's size in bytes, for an integer field to filter or sort on. */
|
|
12
|
+
export declare const fileSize: () => FileProcessor;
|
|
13
|
+
/** The file's detected type, for a text field to filter or sort on. */
|
|
14
|
+
export declare const fileType: () => FileProcessor;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { imageSize } from "image-size";
|
|
2
|
+
//#region src/files/processors.ts
|
|
3
|
+
const shownSize = (head) => {
|
|
4
|
+
let size;
|
|
5
|
+
try {
|
|
6
|
+
size = imageSize(head);
|
|
7
|
+
} catch (error) {
|
|
8
|
+
if (error instanceof TypeError || error instanceof RangeError) return void 0;
|
|
9
|
+
throw error;
|
|
10
|
+
}
|
|
11
|
+
return (size.orientation ?? 1) >= 5 ? {
|
|
12
|
+
width: size.height,
|
|
13
|
+
height: size.width
|
|
14
|
+
} : {
|
|
15
|
+
width: size.width,
|
|
16
|
+
height: size.height
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
/** A processor of the project's own: `processor({ accepts: ['image/*'], run: async (file) => … })`. */
|
|
20
|
+
const processor = (definition) => definition;
|
|
21
|
+
/** An image's width or height in pixels, as shown (EXIF rotation applied), for an integer field. Null for other files. */
|
|
22
|
+
const imageSize$1 = (dimension) => processor({
|
|
23
|
+
accepts: ["image/*"],
|
|
24
|
+
run: (file) => shownSize(file.head)?.[dimension] ?? null
|
|
25
|
+
});
|
|
26
|
+
/** An image's width divided by its height, as decimal text with `decimals` places (default 4), for a decimal field. */
|
|
27
|
+
const aspectRatio = ({ decimals = 4 } = {}) => processor({
|
|
28
|
+
accepts: ["image/*"],
|
|
29
|
+
run: (file) => {
|
|
30
|
+
const size = shownSize(file.head);
|
|
31
|
+
return size && size.height > 0 ? (size.width / size.height).toFixed(decimals) : null;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
/** The file's size in bytes, for an integer field to filter or sort on. */
|
|
35
|
+
const fileSize = () => processor({ run: (file) => file.size });
|
|
36
|
+
/** The file's detected type, for a text field to filter or sort on. */
|
|
37
|
+
const fileType = () => processor({ run: (file) => file.type });
|
|
38
|
+
//#endregion
|
|
39
|
+
export { aspectRatio, fileSize, fileType, imageSize$1 as imageSize, processor };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { parseDuration } from "./duration.js";
|
|
2
|
+
import { localFiles } from "./local-files.js";
|
|
3
|
+
import { checkedProvider } from "@protobase/schema";
|
|
4
|
+
//#region src/files/providers.ts
|
|
5
|
+
const prefix = "PROTOBASE_FILES_";
|
|
6
|
+
/** `PROTOBASE_FILES_<NAME>` configures a provider, except for these settings. */
|
|
7
|
+
const settingVariables = [
|
|
8
|
+
"RETENTION",
|
|
9
|
+
"SECRET",
|
|
10
|
+
"CLEANUP_MINUTES"
|
|
11
|
+
];
|
|
12
|
+
const reservedNames = new Set(settingVariables.map((name) => name.toLowerCase().replaceAll("_", "-")));
|
|
13
|
+
const checkedName = (name) => {
|
|
14
|
+
checkedProvider(name);
|
|
15
|
+
if (reservedNames.has(name)) throw new Error(`"${name}" cannot name a file provider: PROTOBASE_FILES_${name.toUpperCase().replaceAll("-", "_")} is a setting`);
|
|
16
|
+
return name;
|
|
17
|
+
};
|
|
18
|
+
const variableOf = (name) => `${prefix}${name.toUpperCase().replaceAll("-", "_")}`;
|
|
19
|
+
/** A provider from `file:///data/files/private?public=1&public_url=…&retention=14d` (a relative `file:data/files` works too). */
|
|
20
|
+
const providerFromUrl = (text, variable) => {
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(text);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error instanceof TypeError) throw new Error(`${variable} is not a URL; write it like file:///data/files/private`);
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
if (url.protocol !== "file:") throw new Error(`${variable} uses ${url.protocol}; not implemented: only file: providers (local disk) are supported so far`);
|
|
29
|
+
const flag = url.searchParams.get("public");
|
|
30
|
+
const publicUrl = url.searchParams.get("public_url") ?? void 0;
|
|
31
|
+
const retention = url.searchParams.get("retention") ?? void 0;
|
|
32
|
+
return localFiles({
|
|
33
|
+
dir: decodeURIComponent(url.pathname),
|
|
34
|
+
public: flag === "1" || flag === "true",
|
|
35
|
+
...publicUrl && { publicUrl },
|
|
36
|
+
...retention && { retention }
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The providers in effect: the config's, each replaced by `PROTOBASE_FILES_<NAME>` when that is set, plus any the
|
|
41
|
+
* environment adds. `private` and `public` default to `data/files/private` and `data/files/public` when `used` needs them.
|
|
42
|
+
*/
|
|
43
|
+
const resolveProviders = (options, env, used) => {
|
|
44
|
+
const providers = {};
|
|
45
|
+
for (const [name, provider] of Object.entries(options.providers ?? {})) providers[checkedName(name)] = provider;
|
|
46
|
+
for (const [variable, value] of Object.entries(env)) {
|
|
47
|
+
if (!variable.startsWith(prefix) || !value) continue;
|
|
48
|
+
const suffix = variable.slice(16);
|
|
49
|
+
if (settingVariables.includes(suffix)) continue;
|
|
50
|
+
const name = checkedName(suffix.toLowerCase().replaceAll("_", "-"));
|
|
51
|
+
providers[name] = providerFromUrl(value, variable);
|
|
52
|
+
}
|
|
53
|
+
for (const name of used) {
|
|
54
|
+
if (providers[name]) continue;
|
|
55
|
+
if (name !== "private" && name !== "public") throw new Error(`No file provider "${name}": add it to files.providers in protobase.config.ts or set ${variableOf(name)}`);
|
|
56
|
+
providers[name] = localFiles({
|
|
57
|
+
dir: `data/files/${name}`,
|
|
58
|
+
public: name === "public"
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return providers;
|
|
62
|
+
};
|
|
63
|
+
/** How long a provider keeps bytes after their last reference. */
|
|
64
|
+
const retentionOf = (options, env) => {
|
|
65
|
+
const fallback = env[`${prefix}RETENTION`] ?? options.retention ?? "1 day";
|
|
66
|
+
const global = parseDuration(fallback, env[`${prefix}RETENTION`] ? `${prefix}RETENTION` : "files.retention");
|
|
67
|
+
return (provider) => provider.retention ?? (provider.public ? 0 : global);
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
export { providerFromUrl, resolveProviders, retentionOf, settingVariables };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { forbidden } from "../problem.js";
|
|
2
|
+
import { serveFile } from "./download.js";
|
|
3
|
+
import { cleanupFiles } from "./runtime.js";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
//#region src/files/routes.ts
|
|
6
|
+
/**
|
|
7
|
+
* Beside the API, on the app's own host: `GET <systemPath>/files/<provider>/<path>` serves files, and
|
|
8
|
+
* `POST <systemPath>/files:cleanup` runs the scheduled deletes that are due, for an admin (a platform calls it on a schedule).
|
|
9
|
+
*/
|
|
10
|
+
const fileRoutes = (deps, authenticate) => {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
const files = deps.files;
|
|
13
|
+
if (!files) return app;
|
|
14
|
+
app.get(`${files.path}/:provider/*`, (c) => {
|
|
15
|
+
const provider = c.req.param("provider");
|
|
16
|
+
const path = decodeURIComponent(new URL(c.req.url).pathname.slice(`${files.path}/${provider}/`.length));
|
|
17
|
+
return serveFile(deps, c.req.raw, provider, path);
|
|
18
|
+
});
|
|
19
|
+
app.post(`${files.path}:cleanup`, async (c) => {
|
|
20
|
+
if (!(await authenticate(c.req.raw)).user.roles.includes("admin")) throw forbidden("access-denied", "Only an admin may run the file cleanup");
|
|
21
|
+
return c.json(await cleanupFiles(files, deps.db));
|
|
22
|
+
});
|
|
23
|
+
return app;
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { fileRoutes };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { fileColumns, runCleanup } from "./cleanup-runner.js";
|
|
2
|
+
import { storageCleanupSchedule } from "./cleanup-schedule.js";
|
|
3
|
+
import { resolveProviders, retentionOf } from "./providers.js";
|
|
4
|
+
import { createSigner } from "./signer.js";
|
|
5
|
+
//#region src/files/runtime.ts
|
|
6
|
+
/** How long an upload ticket stays usable, so a form left open still saves. */
|
|
7
|
+
const ticketSeconds = 86400;
|
|
8
|
+
/**
|
|
9
|
+
* The runtime, when a resource has a file field or the config names providers. It fails at startup when a field's
|
|
10
|
+
* provider is not configured or nothing signs uploads.
|
|
11
|
+
*/
|
|
12
|
+
const createFilesRuntime = ({ options = {}, env, models, systemPath }) => {
|
|
13
|
+
const used = [...new Set(models.flatMap((model) => Object.values(model.fields).flatMap((field) => field.file ? [field.file.provider] : [])))];
|
|
14
|
+
if (used.length === 0 && !options.providers) return void 0;
|
|
15
|
+
const secret = options.secret ?? env.PROTOBASE_FILES_SECRET ?? env.BETTER_AUTH_SECRET;
|
|
16
|
+
if (!secret) throw new Error("File fields need a secret to sign uploads: set BETTER_AUTH_SECRET, PROTOBASE_FILES_SECRET or files.secret");
|
|
17
|
+
const providers = resolveProviders(options, env, used);
|
|
18
|
+
const retention = retentionOf(options, env);
|
|
19
|
+
return {
|
|
20
|
+
providers,
|
|
21
|
+
signer: createSigner(secret),
|
|
22
|
+
schedule: storageCleanupSchedule(providers),
|
|
23
|
+
retention: (provider) => retention(providers[provider]),
|
|
24
|
+
path: `${systemPath}/files`,
|
|
25
|
+
columns: fileColumns(models)
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** Runs the scheduled deletes that are due (see `runCleanup`). */
|
|
29
|
+
const cleanupFiles = (runtime, db, now) => runCleanup({
|
|
30
|
+
db,
|
|
31
|
+
columns: runtime.columns,
|
|
32
|
+
providers: runtime.providers,
|
|
33
|
+
schedule: runtime.schedule,
|
|
34
|
+
...now && { now }
|
|
35
|
+
});
|
|
36
|
+
//#endregion
|
|
37
|
+
export { cleanupFiles, createFilesRuntime, ticketSeconds };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/files/signer.ts
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
const toBase64Url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
4
|
+
const fromBase64Url = (text) => {
|
|
5
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(text)) return void 0;
|
|
6
|
+
return Uint8Array.from(atob(text.replaceAll("-", "+").replaceAll("_", "/")), (char) => char.charCodeAt(0));
|
|
7
|
+
};
|
|
8
|
+
const createSigner = (secret) => {
|
|
9
|
+
const key = (async () => {
|
|
10
|
+
const material = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
|
|
11
|
+
return crypto.subtle.deriveKey({
|
|
12
|
+
name: "HKDF",
|
|
13
|
+
hash: "SHA-256",
|
|
14
|
+
salt: /* @__PURE__ */ new Uint8Array(0),
|
|
15
|
+
info: encoder.encode("protobase files")
|
|
16
|
+
}, material, {
|
|
17
|
+
name: "HMAC",
|
|
18
|
+
hash: "SHA-256",
|
|
19
|
+
length: 256
|
|
20
|
+
}, false, ["sign", "verify"]);
|
|
21
|
+
})();
|
|
22
|
+
const content = (purpose, parts) => encoder.encode(JSON.stringify([purpose, ...parts]));
|
|
23
|
+
return {
|
|
24
|
+
sign: async (purpose, parts) => toBase64Url(await crypto.subtle.sign("HMAC", await key, content(purpose, parts))),
|
|
25
|
+
verify: async (purpose, parts, signature) => {
|
|
26
|
+
const bytes = fromBase64Url(signature);
|
|
27
|
+
return bytes !== void 0 && crypto.subtle.verify("HMAC", await key, bytes, content(purpose, parts));
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { createSigner };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/files/store.d.ts
|
|
2
|
+
/** One stored object. */
|
|
3
|
+
export type StoredObject = {
|
|
4
|
+
path: string;
|
|
5
|
+
size: number;
|
|
6
|
+
modified: Date;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Where a provider keeps bytes: local disk now, an S3-compatible bucket later. Paths are relative and use `/`. Every
|
|
10
|
+
* operation on a path that does not exist is harmless: `head` and `read` give `undefined`, `delete` does nothing.
|
|
11
|
+
*/
|
|
12
|
+
export type FileStore = {
|
|
13
|
+
/** Writes the whole stream, or nothing when it fails: a reader never sees part of an object. */
|
|
14
|
+
put(path: string, body: ReadableStream<Uint8Array>): Promise<{
|
|
15
|
+
size: number;
|
|
16
|
+
}>;
|
|
17
|
+
head(path: string): Promise<StoredObject | undefined>;
|
|
18
|
+
/** The bytes, or the inclusive byte range `start`–`end`. */
|
|
19
|
+
read(path: string, range?: {
|
|
20
|
+
start: number;
|
|
21
|
+
end: number;
|
|
22
|
+
}): Promise<ReadableStream<Uint8Array> | undefined>;
|
|
23
|
+
delete(path: string): Promise<void>;
|
|
24
|
+
/** Every object under `prefix` (a folder such as `.cleanup/`), sorted by path. */
|
|
25
|
+
list(prefix: string): Promise<StoredObject[]>;
|
|
26
|
+
};
|
|
27
|
+
/** A configured provider: its store, whether its files are public, and how long bytes outlive their last reference. */
|
|
28
|
+
export type FileProvider = {
|
|
29
|
+
store: FileStore;
|
|
30
|
+
/** Served to anyone at a permanent URL. */
|
|
31
|
+
public: boolean;
|
|
32
|
+
/** Where public files are linked from, such as a CDN; default the app's own `/api/files/<provider>`. */
|
|
33
|
+
publicUrl?: string;
|
|
34
|
+
/** Milliseconds between a file leaving its row and its bytes being deleted; default `files.retention` (0 when public). */
|
|
35
|
+
retention?: number;
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { formatUri, parseUri } from "./uri.js";
|
|
2
|
+
//#region src/files/ticket.ts
|
|
3
|
+
const sortedEntries = (derived) => Object.entries(derived).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
4
|
+
const parts = (uri, derived, scope, expires) => [
|
|
5
|
+
uri,
|
|
6
|
+
sortedEntries(derived),
|
|
7
|
+
scope.tenant,
|
|
8
|
+
scope.resource,
|
|
9
|
+
scope.field,
|
|
10
|
+
scope.user,
|
|
11
|
+
expires
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* What `:upload` answers with, and what a write sends back: the file's URI with its derived values, an expiry (Unix
|
|
15
|
+
* seconds) and a signature over them and the scope, so it works only for that uploader, tenant, resource and field.
|
|
16
|
+
*/
|
|
17
|
+
const issueTicket = async (signer, ref, derived, scope, expires) => {
|
|
18
|
+
const uri = formatUri(ref);
|
|
19
|
+
return `${uri}&${new URLSearchParams([
|
|
20
|
+
...sortedEntries(derived).map(([column, value]) => [`d.${column}`, JSON.stringify(value)]),
|
|
21
|
+
["exp", String(expires)],
|
|
22
|
+
["sig", await signer.sign("ticket", parts(uri, derived, scope, expires))]
|
|
23
|
+
])}`;
|
|
24
|
+
};
|
|
25
|
+
const parseJson = (text) => {
|
|
26
|
+
try {
|
|
27
|
+
return { value: JSON.parse(text) };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof SyntaxError) return void 0;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** The clean URI and derived values of a ticket that verifies for `scope` and has not expired at `now` (Unix seconds). */
|
|
34
|
+
const readTicket = async (signer, value, scope, now) => {
|
|
35
|
+
const query = new URLSearchParams(value.slice(value.indexOf("?") + 1));
|
|
36
|
+
const signature = query.get("sig");
|
|
37
|
+
if (!value.includes("?") || signature === null) return {
|
|
38
|
+
ok: false,
|
|
39
|
+
reason: "not-a-ticket"
|
|
40
|
+
};
|
|
41
|
+
const derived = {};
|
|
42
|
+
for (const [name, text] of query) {
|
|
43
|
+
if (!name.startsWith("d.")) continue;
|
|
44
|
+
const parsed = parseJson(text);
|
|
45
|
+
if (!parsed) return {
|
|
46
|
+
ok: false,
|
|
47
|
+
reason: "invalid"
|
|
48
|
+
};
|
|
49
|
+
derived[name.slice(2)] = parsed.value;
|
|
50
|
+
}
|
|
51
|
+
const known = /* @__PURE__ */ new Set([
|
|
52
|
+
"name",
|
|
53
|
+
"size",
|
|
54
|
+
"exp",
|
|
55
|
+
"sig"
|
|
56
|
+
]);
|
|
57
|
+
if ([...query.keys()].some((name) => !known.has(name) && !name.startsWith("d."))) return {
|
|
58
|
+
ok: false,
|
|
59
|
+
reason: "invalid"
|
|
60
|
+
};
|
|
61
|
+
const parsed = parseUri(value.slice(0, value.indexOf("?")) + `?${new URLSearchParams({
|
|
62
|
+
name: query.get("name") ?? "",
|
|
63
|
+
size: query.get("size") ?? ""
|
|
64
|
+
})}`);
|
|
65
|
+
const expires = Number(query.get("exp"));
|
|
66
|
+
if (!parsed || parsed.size === void 0 || !Number.isSafeInteger(expires)) return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: "invalid"
|
|
69
|
+
};
|
|
70
|
+
const uri = formatUri(parsed);
|
|
71
|
+
if (!await signer.verify("ticket", parts(uri, derived, scope, expires), signature)) return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: "invalid"
|
|
74
|
+
};
|
|
75
|
+
if (expires < now) return {
|
|
76
|
+
ok: false,
|
|
77
|
+
reason: "expired"
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
uri,
|
|
82
|
+
ref: parsed,
|
|
83
|
+
derived
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
87
|
+
export { issueTicket, readTicket };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { HttpProblem, badRequest } from "../problem.js";
|
|
2
|
+
//#region src/files/upload-body.ts
|
|
3
|
+
const tooLarge = (maxSize) => new HttpProblem(413, "file-too-large", "Payload Too Large", `The file is larger than this field allows (${maxSize} bytes)`);
|
|
4
|
+
const multipartSlack = 65536;
|
|
5
|
+
/**
|
|
6
|
+
* The file of an upload request: the raw body with `?name=` and its Content-Type, or the one file part of a
|
|
7
|
+
* `multipart/form-data` body with that part's file name and type. A multipart body is read whole, after its size is
|
|
8
|
+
* checked; a raw body streams.
|
|
9
|
+
*/
|
|
10
|
+
const incomingFile = async (request, maxSize) => {
|
|
11
|
+
const header = request.headers.get("content-length");
|
|
12
|
+
const length = header === null ? void 0 : Number(header);
|
|
13
|
+
const contentType = request.headers.get("content-type") ?? void 0;
|
|
14
|
+
const name = new URL(request.url).searchParams.get("name") ?? void 0;
|
|
15
|
+
if (!contentType?.toLowerCase().startsWith("multipart/form-data")) {
|
|
16
|
+
if (length !== void 0 && length > maxSize) throw tooLarge(maxSize);
|
|
17
|
+
return {
|
|
18
|
+
body: request.body ?? new Blob([]).stream(),
|
|
19
|
+
...name !== void 0 && { name },
|
|
20
|
+
...contentType && { declared: contentType },
|
|
21
|
+
...length !== void 0 && { length }
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (length !== void 0 && length > maxSize + multipartSlack) throw tooLarge(maxSize);
|
|
25
|
+
const files = [...(await request.formData()).values()].filter((value) => typeof value !== "string");
|
|
26
|
+
if (files.length !== 1) throw badRequest("invalid-upload", `A multipart upload carries exactly one file part, not ${files.length}`);
|
|
27
|
+
const file = files[0];
|
|
28
|
+
if (file.size > maxSize) throw tooLarge(maxSize);
|
|
29
|
+
return {
|
|
30
|
+
body: file.stream(),
|
|
31
|
+
name: file.name || name,
|
|
32
|
+
...file.type && { declared: file.type },
|
|
33
|
+
length: file.size
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
const concat = (chunks, length) => {
|
|
37
|
+
const joined = new Uint8Array(length);
|
|
38
|
+
let offset = 0;
|
|
39
|
+
for (const chunk of chunks) {
|
|
40
|
+
joined.set(chunk, offset);
|
|
41
|
+
offset += chunk.length;
|
|
42
|
+
}
|
|
43
|
+
return joined;
|
|
44
|
+
};
|
|
45
|
+
/** The first `want` bytes (or all, when there are fewer), and whether that was the whole stream. */
|
|
46
|
+
const readHead = async (reader, want) => {
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let length = 0;
|
|
49
|
+
while (length < want) {
|
|
50
|
+
const next = await reader.read();
|
|
51
|
+
if (next.done) return {
|
|
52
|
+
head: concat(chunks, length),
|
|
53
|
+
complete: true
|
|
54
|
+
};
|
|
55
|
+
chunks.push(next.value);
|
|
56
|
+
length += next.value.length;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
head: concat(chunks, length),
|
|
60
|
+
complete: false
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
var FileTooLarge = class extends Error {};
|
|
64
|
+
/** The head followed by the rest of the reader, failing with `FileTooLarge` past `maxSize` bytes. */
|
|
65
|
+
const limitedStream = (head, reader, maxSize) => {
|
|
66
|
+
let size = head.length;
|
|
67
|
+
return new ReadableStream({
|
|
68
|
+
start: (controller) => {
|
|
69
|
+
if (head.length > 0) controller.enqueue(head);
|
|
70
|
+
},
|
|
71
|
+
pull: async (controller) => {
|
|
72
|
+
const next = await reader.read();
|
|
73
|
+
if (next.done) return controller.close();
|
|
74
|
+
size += next.value.length;
|
|
75
|
+
if (size > maxSize) {
|
|
76
|
+
await reader.cancel();
|
|
77
|
+
return controller.error(new FileTooLarge());
|
|
78
|
+
}
|
|
79
|
+
controller.enqueue(next.value);
|
|
80
|
+
},
|
|
81
|
+
cancel: (reason) => reader.cancel(reason)
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
//#endregion
|
|
85
|
+
export { FileTooLarge, incomingFile, limitedStream, readHead, tooLarge };
|