@protobase/server 0.12.16 → 0.12.18

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.
@@ -1,7 +1,7 @@
1
1
  import { HttpProblem, forbidden, notFound } from "../problem.js";
2
2
  import { decodeKeyText } from "../key.js";
3
- import { readDownloadUrl } from "./download-url.js";
4
- import { downloadName, isInline, servedType } from "./media-types.js";
3
+ import { downloadExpiry, readDownloadUrl } from "./download-url.js";
4
+ import { contentDisposition, downloadName, servedType } from "./media-types.js";
5
5
  import { baseOf, isFilePath, parseUri, tenantSegment } from "./uri.js";
6
6
  import { fetchRecord } from "../records.js";
7
7
  import { readTransaction } from "../transactions.js";
@@ -18,10 +18,6 @@ const rangeOf = (header, size) => {
18
18
  end
19
19
  } : false;
20
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
21
  const grantedName = async (deps, provider, path, query) => {
26
22
  const read = await readDownloadUrl(deps.files.signer, provider, path, query);
27
23
  if (read.status === "invalid") throw forbidden("invalid-signature", "This file link is not valid");
@@ -44,23 +40,40 @@ const grantedName = async (deps, provider, path, query) => {
44
40
  expires: grant.expires
45
41
  };
46
42
  };
43
+ const redirectToStore = async (store, path, type, name, expires) => {
44
+ const location = await store.downloadUrl(path, {
45
+ expires,
46
+ type,
47
+ disposition: contentDisposition(name, type)
48
+ });
49
+ const seconds = Math.max(expires - Math.floor(Date.now() / 1e3), 0);
50
+ return new Response(null, {
51
+ status: 302,
52
+ headers: {
53
+ location,
54
+ "cache-control": `private, max-age=${seconds}`
55
+ }
56
+ });
57
+ };
47
58
  /**
48
59
  * `GET <systemPath>/files/<provider>/<path>`: a public provider's file for anyone; a private one's only through a signed
49
60
  * 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.
61
+ * nothing renders: `nosniff`, a sandboxing CSP, and `inline` only for images, PDF, audio and video. A store with its own
62
+ * download links gets a redirect to one instead, with the same type and disposition.
51
63
  */
52
64
  const serveFile = async (deps, request, provider, path) => {
53
65
  const configured = deps.files?.providers[provider];
54
66
  if (!configured || !isFilePath(path)) throw gone();
55
67
  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
68
  const type = servedType(path);
59
69
  const name = downloadName(granted?.name ?? path.slice(path.lastIndexOf("/") + 1), type);
70
+ if (configured.store.downloadUrl) return redirectToStore(configured.store, path, type, name, granted?.expires ?? downloadExpiry());
71
+ const object = await configured.store.head(path);
72
+ if (!object) throw gone();
60
73
  const caching = granted ? `private, max-age=${Math.max(granted.expires - Math.floor(Date.now() / 1e3), 0)}` : "public, max-age=31536000, immutable";
61
74
  const headers = {
62
75
  "content-type": type,
63
- "content-disposition": disposition(name, type),
76
+ "content-disposition": contentDisposition(name, type),
64
77
  "x-content-type-options": "nosniff",
65
78
  "content-security-policy": "sandbox; default-src 'none'",
66
79
  "cache-control": caching,
@@ -0,0 +1,15 @@
1
+ import { FileStore } from "./store.js";
2
+ //#region src/files/limits.d.ts
3
+ /** The most a provider may hold, in bytes and in files; a bucket has no quota of its own. */
4
+ export type FileLimits = {
5
+ maxBytes?: number | string;
6
+ maxFiles?: number;
7
+ };
8
+ /**
9
+ * `store` refusing a file with `507` once it would hold more than `maxBytes` or `maxFiles`. Only files count, not the
10
+ * store's own `.cleanup/` entries. What the store holds is counted with `list` on the first upload and again when the
11
+ * count is older than `recountMs`, and in between follows this instance's own writes: several instances, or a delete
12
+ * made elsewhere, can be off by what happened since the last count.
13
+ */
14
+ export declare const limitedFileStore: (store: FileStore, limits: FileLimits, now?: () => number) => FileStore;
15
+ //#endregion
@@ -0,0 +1,79 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ import { isFilePath } from "./uri.js";
3
+ import { parseSize } from "@protobase/schema";
4
+ //#region src/files/limits.ts
5
+ /** How long a count of what a store holds is trusted before it is taken again. */
6
+ const recountMs = 3e5;
7
+ const limitReached = () => new HttpProblem(507, "storage-full", "Insufficient Storage", "The file storage is full; delete files to make room");
8
+ var LimitReached = class extends Error {};
9
+ const checkedFiles = (value) => {
10
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`maxFiles must be a positive whole number, not ${value}`);
11
+ return value;
12
+ };
13
+ /**
14
+ * `store` refusing a file with `507` once it would hold more than `maxBytes` or `maxFiles`. Only files count, not the
15
+ * store's own `.cleanup/` entries. What the store holds is counted with `list` on the first upload and again when the
16
+ * count is older than `recountMs`, and in between follows this instance's own writes: several instances, or a delete
17
+ * made elsewhere, can be off by what happened since the last count.
18
+ */
19
+ const limitedFileStore = (store, limits, now = Date.now) => {
20
+ if (limits.maxBytes === void 0 && limits.maxFiles === void 0) return store;
21
+ const maxBytes = limits.maxBytes === void 0 ? Infinity : parseSize(limits.maxBytes);
22
+ const maxFiles = limits.maxFiles === void 0 ? Infinity : checkedFiles(limits.maxFiles);
23
+ let counted;
24
+ const pending = {
25
+ bytes: 0,
26
+ files: 0
27
+ };
28
+ const usage = async () => {
29
+ if (counted && now() - counted.at < 3e5) return counted;
30
+ const files = (await store.list("")).filter((object) => isFilePath(object.path));
31
+ counted = {
32
+ bytes: files.reduce((sum, object) => sum + object.size, 0),
33
+ files: files.length,
34
+ at: now()
35
+ };
36
+ return counted;
37
+ };
38
+ const put = async (path, body) => {
39
+ if (!isFilePath(path)) return store.put(path, body);
40
+ const stored = await usage();
41
+ if (stored.files + pending.files + 1 > maxFiles) {
42
+ await body.cancel();
43
+ throw limitReached();
44
+ }
45
+ let size = 0;
46
+ const counting = new TransformStream({ transform: (chunk, controller) => {
47
+ size += chunk.length;
48
+ pending.bytes += chunk.length;
49
+ if (stored.bytes + pending.bytes > maxBytes) throw new LimitReached();
50
+ controller.enqueue(chunk);
51
+ } });
52
+ pending.files += 1;
53
+ try {
54
+ const written = await store.put(path, body.pipeThrough(counting));
55
+ if (counted) {
56
+ counted.bytes += written.size;
57
+ counted.files += 1;
58
+ }
59
+ return written;
60
+ } catch (error) {
61
+ if (error instanceof LimitReached) throw limitReached();
62
+ throw error;
63
+ } finally {
64
+ pending.files -= 1;
65
+ pending.bytes -= size;
66
+ }
67
+ };
68
+ const remove = async (path) => {
69
+ await store.delete(path);
70
+ if (isFilePath(path)) counted = void 0;
71
+ };
72
+ return {
73
+ ...store,
74
+ put,
75
+ delete: remove
76
+ };
77
+ };
78
+ //#endregion
79
+ export { limitedFileStore, recountMs };
@@ -1,6 +1,7 @@
1
1
  import { FileProvider, FileStore } from "./store.js";
2
+ import { FileLimits } from "./limits.js";
2
3
  //#region src/files/local-files.d.ts
3
- export type LocalFilesOptions = {
4
+ export type LocalFilesOptions = FileLimits & {
4
5
  /** The folder the files live in, created when missing; relative to the working directory. */
5
6
  dir: string;
6
7
  /** Files anyone with the link may open, served without a signature. */
@@ -13,5 +14,5 @@ export type LocalFilesOptions = {
13
14
  /** A store in a folder on local disk. Writes go to `.tmp/` first and are renamed into place, so they are atomic. */
14
15
  export declare const localFileStore: (dir: string) => FileStore;
15
16
  /** A provider on local disk: `localFiles({ dir: './data/files/private' })`. */
16
- export declare const localFiles: ({ dir, public: isPublic, publicUrl, retention }: LocalFilesOptions) => FileProvider;
17
+ export declare const localFiles: ({ dir, public: isPublic, publicUrl, retention, maxBytes, maxFiles }: LocalFilesOptions) => FileProvider;
17
18
  //#endregion
@@ -1,5 +1,6 @@
1
1
  import { HttpProblem } from "../problem.js";
2
2
  import { parseDuration } from "./duration.js";
3
+ import { limitedFileStore } from "./limits.js";
3
4
  //#region src/files/local-files.ts
4
5
  const node = async () => ({
5
6
  fs: await import("node:fs/promises"),
@@ -66,7 +67,7 @@ const localFileStore = (dir) => {
66
67
  const list = async (prefix) => {
67
68
  const { fs, path } = await node();
68
69
  const root = path.resolve(dir);
69
- const folder = await resolve(prefix.replace(/\/$/, ""));
70
+ const folder = prefix === "" ? root : await resolve(prefix.replace(/\/$/, ""));
70
71
  let names;
71
72
  try {
72
73
  names = await fs.readdir(folder, { recursive: true });
@@ -91,8 +92,11 @@ const localFileStore = (dir) => {
91
92
  };
92
93
  };
93
94
  /** A provider on local disk: `localFiles({ dir: './data/files/private' })`. */
94
- const localFiles = ({ dir, public: isPublic = false, publicUrl, retention }) => ({
95
- store: localFileStore(dir),
95
+ const localFiles = ({ dir, public: isPublic = false, publicUrl, retention, maxBytes, maxFiles }) => ({
96
+ store: limitedFileStore(localFileStore(dir), {
97
+ maxBytes,
98
+ maxFiles
99
+ }),
96
100
  public: isPublic || publicUrl !== void 0,
97
101
  ...publicUrl !== void 0 && { publicUrl: publicUrl.replace(/\/$/, "") },
98
102
  ...retention !== void 0 && { retention: parseDuration(retention, "retention") }
@@ -84,6 +84,11 @@ const servedType = (path) => typeOfExtension(path) ?? "application/octet-stream"
84
84
  const canonicalExtension = (type) => byType.get(type);
85
85
  const isKnownType = (type) => byType.has(type);
86
86
  const isInline = (type) => inlineTypes.has(type) || type.startsWith("audio/") || type.startsWith("video/");
87
+ /** `inline` for types shown in the browser, else `attachment`, named `name` in ASCII and in UTF-8. */
88
+ const contentDisposition = (name, type) => {
89
+ const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
90
+ return `${isInline(type) ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
91
+ };
87
92
  /** Whether `type` passes a field's allowed list: exact types, `type/*` wildcards, `*\/*`; an empty list allows any. */
88
93
  const accepts = (allowed, type) => allowed.length === 0 || allowed.some((entry) => entry === "*/*" || entry === type || entry.endsWith("/*") && type.startsWith(entry.slice(0, -1)));
89
94
  /** The uploader's name with its extension made to match the stored type: `photo.png` holding a JPEG downloads as `photo.jpg`. */
@@ -96,4 +101,4 @@ const downloadName = (name, type) => {
96
101
  return `${extension !== void 0 && known !== void 0 ? name.slice(0, -extension.length - 1) : name}.${canonical}`;
97
102
  };
98
103
  //#endregion
99
- export { accepts, canonicalExtension, downloadName, extensionOf, isInline, isKnownType, octetStream, servedType, textTypes, typeOfExtension };
104
+ export { accepts, canonicalExtension, contentDisposition, downloadName, extensionOf, isInline, isKnownType, octetStream, servedType, textTypes, typeOfExtension };
@@ -1,5 +1,6 @@
1
1
  import { parseDuration } from "./duration.js";
2
2
  import { localFiles } from "./local-files.js";
3
+ import { s3Files } from "./s3-files.js";
3
4
  import { checkedProvider } from "@protobase/schema";
4
5
  //#region src/files/providers.ts
5
6
  const prefix = "PROTOBASE_FILES_";
@@ -10,14 +11,54 @@ const settingVariables = [
10
11
  "CLEANUP_MINUTES"
11
12
  ];
12
13
  const reservedNames = new Set(settingVariables.map((name) => name.toLowerCase().replaceAll("_", "-")));
14
+ const credentialSuffixes = ["_ACCESS_KEY_ID", "_SECRET_ACCESS_KEY"];
13
15
  const checkedName = (name) => {
14
16
  checkedProvider(name);
15
17
  if (reservedNames.has(name)) throw new Error(`"${name}" cannot name a file provider: PROTOBASE_FILES_${name.toUpperCase().replaceAll("-", "_")} is a setting`);
18
+ const suffix = credentialSuffixes.find((ending) => name.endsWith(ending.toLowerCase().replaceAll("_", "-")));
19
+ if (suffix) throw new Error(`"${name}" cannot name a file provider: variables ending in ${suffix} hold credentials`);
16
20
  return name;
17
21
  };
18
22
  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) => {
23
+ const credentialsOf = (variable, env) => {
24
+ for (const owner of [
25
+ `${variable}_`,
26
+ "S3_",
27
+ "AWS_"
28
+ ]) {
29
+ const accessKeyId = env[`${owner}ACCESS_KEY_ID`];
30
+ const secretAccessKey = env[`${owner}SECRET_ACCESS_KEY`];
31
+ if (!accessKeyId && !secretAccessKey) continue;
32
+ if (!accessKeyId || !secretAccessKey) throw new Error(`Set both ${owner}ACCESS_KEY_ID and ${owner}SECRET_ACCESS_KEY, or neither`);
33
+ return {
34
+ accessKeyId,
35
+ secretAccessKey
36
+ };
37
+ }
38
+ throw new Error(`${variable} needs credentials: set ${variable}_ACCESS_KEY_ID and ${variable}_SECRET_ACCESS_KEY`);
39
+ };
40
+ const sharedOptions = (url, variable) => {
41
+ const flag = url.searchParams.get("public");
42
+ const publicUrl = url.searchParams.get("public_url") ?? void 0;
43
+ const retention = url.searchParams.get("retention") ?? void 0;
44
+ const maxBytes = url.searchParams.get("max_bytes") ?? void 0;
45
+ const maxFiles = url.searchParams.get("max_files") ?? void 0;
46
+ if (maxFiles !== void 0 && !/^\d+$/.test(maxFiles)) throw new Error(`${variable} has max_files=${maxFiles}; write a whole number of files`);
47
+ return {
48
+ public: flag === "1" || flag === "true",
49
+ ...publicUrl && { publicUrl },
50
+ ...retention && { retention },
51
+ ...maxBytes && { maxBytes },
52
+ ...maxFiles && { maxFiles: Number(maxFiles) }
53
+ };
54
+ };
55
+ /**
56
+ * A provider from a URL: `file:///data/files/private` (a relative `file:data/files` works too) or
57
+ * `s3://<bucket>/<prefix>?endpoint=…&region=…`, both taking `public=1`, `public_url=…`, `retention=14d`,
58
+ * `max_bytes=1GB` and `max_files=20000`. An s3: provider's credentials come from `<variable>_ACCESS_KEY_ID` and
59
+ * `<variable>_SECRET_ACCESS_KEY`, else `S3_…`, else `AWS_…`.
60
+ */
61
+ const providerFromUrl = (text, variable, env = {}) => {
21
62
  let url;
22
63
  try {
23
64
  url = new URL(text);
@@ -25,15 +66,22 @@ const providerFromUrl = (text, variable) => {
25
66
  if (error instanceof TypeError) throw new Error(`${variable} is not a URL; write it like file:///data/files/private`);
26
67
  throw error;
27
68
  }
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({
69
+ if (url.protocol === "file:") return localFiles({
33
70
  dir: decodeURIComponent(url.pathname),
34
- public: flag === "1" || flag === "true",
35
- ...publicUrl && { publicUrl },
36
- ...retention && { retention }
71
+ ...sharedOptions(url, variable)
72
+ });
73
+ if (url.protocol !== "s3:") throw new Error(`${variable} uses ${url.protocol}; not implemented: only file: (local disk) and s3: (S3-compatible buckets) providers are supported`);
74
+ if (url.username || url.password) throw new Error(`${variable} must not hold credentials; set ${variable}_ACCESS_KEY_ID and ${variable}_SECRET_ACCESS_KEY instead`);
75
+ if (!url.hostname) throw new Error(`${variable} names no bucket; write it like s3://bucket/prefix?endpoint=https://…`);
76
+ const endpoint = url.searchParams.get("endpoint") ?? void 0;
77
+ const region = url.searchParams.get("region") ?? void 0;
78
+ return s3Files({
79
+ bucket: url.hostname,
80
+ prefix: decodeURIComponent(url.pathname),
81
+ ...endpoint && { endpoint },
82
+ ...region && { region },
83
+ ...credentialsOf(variable, env),
84
+ ...sharedOptions(url, variable)
37
85
  });
38
86
  };
39
87
  /**
@@ -47,8 +95,9 @@ const resolveProviders = (options, env, used) => {
47
95
  if (!variable.startsWith(prefix) || !value) continue;
48
96
  const suffix = variable.slice(16);
49
97
  if (settingVariables.includes(suffix)) continue;
98
+ if (credentialSuffixes.some((ending) => suffix.endsWith(ending))) continue;
50
99
  const name = checkedName(suffix.toLowerCase().replaceAll("_", "-"));
51
- providers[name] = providerFromUrl(value, variable);
100
+ providers[name] = providerFromUrl(value, variable, env);
52
101
  }
53
102
  for (const name of used) {
54
103
  if (providers[name]) continue;
@@ -0,0 +1,67 @@
1
+ import { FileProvider, FileStore } from "./store.js";
2
+ import { FileLimits } from "./limits.js";
3
+ //#region src/files/s3-files.d.ts
4
+ /** The part of Bun's `S3Client` the store uses, so tests can pass their own. */
5
+ export type S3Bucket = {
6
+ file(key: string): S3Object;
7
+ list(input: {
8
+ prefix: string;
9
+ continuationToken?: string;
10
+ }): Promise<S3ListPage>;
11
+ };
12
+ export type S3Object = {
13
+ /** Bytes written. A streamed body goes up in parts, and an upload that fails leaves no object. */
14
+ write(body: Response, options: {
15
+ type: string;
16
+ contentDisposition: string;
17
+ }): Promise<number>;
18
+ stat(): Promise<{
19
+ size: number;
20
+ lastModified: Date;
21
+ }>;
22
+ /** The bytes from `start` up to, not including, `end`. */
23
+ slice(start: number, end: number): {
24
+ stream(): ReadableStream<Uint8Array>;
25
+ };
26
+ stream(): ReadableStream<Uint8Array>;
27
+ delete(): Promise<void>;
28
+ /** A GET link, served with the given Content-Type and Content-Disposition. */
29
+ presign(options: {
30
+ expiresIn: number;
31
+ type: string;
32
+ contentDisposition: string;
33
+ }): string;
34
+ };
35
+ export type S3ListPage = {
36
+ contents?: Array<{
37
+ key: string;
38
+ size?: number;
39
+ lastModified?: string;
40
+ }>;
41
+ isTruncated?: boolean;
42
+ nextContinuationToken?: string;
43
+ };
44
+ export type S3FilesOptions = FileLimits & {
45
+ bucket: string;
46
+ /** The folder in the bucket that holds this provider's files, such as `acme/private`; default the bucket's root. */
47
+ prefix?: string;
48
+ /** The S3 API's address, such as `https://<account>.eu.r2.cloudflarestorage.com`; default AWS. */
49
+ endpoint?: string;
50
+ /** `auto` for R2, `fr-par` for Scaleway in Paris. */
51
+ region?: string;
52
+ /** Default `S3_ACCESS_KEY_ID`, then `AWS_ACCESS_KEY_ID`, from the environment. */
53
+ accessKeyId?: string;
54
+ /** Default `S3_SECRET_ACCESS_KEY`, then `AWS_SECRET_ACCESS_KEY`, from the environment. */
55
+ secretAccessKey?: string;
56
+ /** Files anyone with the link may open. */
57
+ public?: boolean;
58
+ /** The bucket's public address, such as an R2 custom domain; public files are linked from there, under `prefix`. */
59
+ publicUrl?: string;
60
+ /** How long bytes outlive their last reference; default `files.retention`, or 0 for a public provider. */
61
+ retention?: number | string;
62
+ };
63
+ /** A store in a bucket, under `prefix`. Downloads get presigned links, so their bytes never pass through the app. */
64
+ export declare const s3FileStore: (bucket: S3Bucket, prefix?: string) => FileStore;
65
+ /** A provider in an S3-compatible bucket, such as Cloudflare R2 or Scaleway: `s3Files({ bucket: 'acme-files', ... })`. */
66
+ export declare const s3Files: (options: S3FilesOptions) => FileProvider;
67
+ //#endregion
@@ -0,0 +1,113 @@
1
+ import { contentDisposition, servedType } from "./media-types.js";
2
+ import { parseDuration } from "./duration.js";
3
+ import { limitedFileStore } from "./limits.js";
4
+ //#region src/files/s3-files.ts
5
+ const codeOf = (error) => error instanceof Error ? error.code : void 0;
6
+ /** A store in a bucket, under `prefix`. Downloads get presigned links, so their bytes never pass through the app. */
7
+ const s3FileStore = (bucket, prefix = "") => {
8
+ const trimmed = prefix.replace(/^\/+|\/+$/g, "");
9
+ const root = trimmed === "" ? "" : `${trimmed}/`;
10
+ const keyOf = (path) => `${root}${path}`;
11
+ const head = async (path) => {
12
+ try {
13
+ const stat = await bucket.file(keyOf(path)).stat();
14
+ return {
15
+ path,
16
+ size: stat.size,
17
+ modified: stat.lastModified
18
+ };
19
+ } catch (error) {
20
+ if (codeOf(error) === "NoSuchKey" || codeOf(error) === "NotFound") return void 0;
21
+ throw error;
22
+ }
23
+ };
24
+ const put = async (path, body) => {
25
+ const type = servedType(path);
26
+ const name = path.slice(path.lastIndexOf("/") + 1);
27
+ return { size: await bucket.file(keyOf(path)).write(new Response(body), {
28
+ type,
29
+ contentDisposition: contentDisposition(name, type)
30
+ }) };
31
+ };
32
+ const read = async (path, range) => {
33
+ if (!await head(path)) return void 0;
34
+ const file = bucket.file(keyOf(path));
35
+ return range ? file.slice(range.start, range.end + 1).stream() : file.stream();
36
+ };
37
+ const remove = (path) => bucket.file(keyOf(path)).delete();
38
+ const list = async (folder) => {
39
+ const found = [];
40
+ let continuationToken;
41
+ do {
42
+ const page = await bucket.list({
43
+ prefix: keyOf(folder),
44
+ ...continuationToken !== void 0 && { continuationToken }
45
+ });
46
+ for (const item of page.contents ?? []) {
47
+ if (item.key.endsWith("/")) continue;
48
+ found.push({
49
+ path: item.key.slice(root.length),
50
+ size: item.size ?? 0,
51
+ modified: new Date(item.lastModified ?? 0)
52
+ });
53
+ }
54
+ if (page.isTruncated && !page.nextContinuationToken) throw new Error(`Listing "${keyOf(folder)}" stopped without a continuation token`);
55
+ continuationToken = page.isTruncated ? page.nextContinuationToken : void 0;
56
+ } while (continuationToken !== void 0);
57
+ return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
58
+ };
59
+ const downloadUrl = async (path, options) => {
60
+ const expiresIn = Math.max(options.expires - Math.floor(Date.now() / 1e3), 1);
61
+ return bucket.file(keyOf(path)).presign({
62
+ expiresIn,
63
+ type: options.type,
64
+ contentDisposition: options.disposition
65
+ });
66
+ };
67
+ return {
68
+ put,
69
+ head,
70
+ read,
71
+ delete: remove,
72
+ list,
73
+ downloadUrl
74
+ };
75
+ };
76
+ const bunBucket = (options) => {
77
+ let client;
78
+ const connect = () => {
79
+ if (client) return client;
80
+ const bun = globalThis.Bun;
81
+ if (!bun) throw new Error("Not implemented: S3 file providers need Bun; serve the app with protobase-serve.js or the protobase image");
82
+ client = new bun.S3Client(options);
83
+ return client;
84
+ };
85
+ return {
86
+ file: (key) => connect().file(key),
87
+ list: (input) => connect().list(input)
88
+ };
89
+ };
90
+ /** A provider in an S3-compatible bucket, such as Cloudflare R2 or Scaleway: `s3Files({ bucket: 'acme-files', ... })`. */
91
+ const s3Files = (options) => {
92
+ const { bucket, prefix = "", endpoint, region, accessKeyId, secretAccessKey, public: isPublic = false, publicUrl, retention, maxBytes, maxFiles } = options;
93
+ if (!bucket) throw new Error("s3Files needs a bucket");
94
+ const client = bunBucket({
95
+ bucket,
96
+ ...endpoint !== void 0 && { endpoint },
97
+ ...region !== void 0 && { region },
98
+ ...accessKeyId !== void 0 && { accessKeyId },
99
+ ...secretAccessKey !== void 0 && { secretAccessKey }
100
+ });
101
+ const folder = prefix.replace(/^\/+|\/+$/g, "");
102
+ return {
103
+ store: limitedFileStore(s3FileStore(client, folder), {
104
+ maxBytes,
105
+ maxFiles
106
+ }),
107
+ public: isPublic || publicUrl !== void 0,
108
+ ...publicUrl !== void 0 && { publicUrl: `${publicUrl.replace(/\/$/, "")}${folder === "" ? "" : `/${folder}`}` },
109
+ ...retention !== void 0 && { retention: parseDuration(retention, "retention") }
110
+ };
111
+ };
112
+ //#endregion
113
+ export { s3FileStore, s3Files };
@@ -6,7 +6,7 @@ export type StoredObject = {
6
6
  modified: Date;
7
7
  };
8
8
  /**
9
- * Where a provider keeps bytes: local disk now, an S3-compatible bucket later. Paths are relative and use `/`. Every
9
+ * Where a provider keeps bytes: a folder on local disk or an S3-compatible bucket. Paths are relative and use `/`. Every
10
10
  * operation on a path that does not exist is harmless: `head` and `read` give `undefined`, `delete` does nothing.
11
11
  */
12
12
  export type FileStore = {
@@ -21,8 +21,17 @@ export type FileStore = {
21
21
  end: number;
22
22
  }): Promise<ReadableStream<Uint8Array> | undefined>;
23
23
  delete(path: string): Promise<void>;
24
- /** Every object under `prefix` (a folder such as `.cleanup/`), sorted by path. */
24
+ /** Every object under `prefix` (a folder such as `.cleanup/`, or `''` for all), sorted by path. */
25
25
  list(prefix: string): Promise<StoredObject[]>;
26
+ /**
27
+ * A link that serves the bytes straight from storage until `expires` (seconds since the epoch), as `type` with the
28
+ * given Content-Disposition. Downloads redirect to it after their checks; without it the app serves the bytes itself.
29
+ */
30
+ downloadUrl?(path: string, options: {
31
+ expires: number;
32
+ type: string;
33
+ disposition: string;
34
+ }): Promise<string>;
26
35
  };
27
36
  /** A configured provider: its store, whether its files are public, and how long bytes outlive their last reference. */
28
37
  export type FileProvider = {
package/dist/index.d.ts CHANGED
@@ -66,7 +66,9 @@ import { startErrorReporting } from "./error-reporting/error-reporter.js";
66
66
  import { FunctionEnv, PublicFunctionEnv, functionApp } from "./functions/function-app.js";
67
67
  import { SignatureOptions, StandardWebhookOptions, verifySignature, verifyStandardWebhook } from "./functions/verify-signature.js";
68
68
  import { requireEnv } from "./functions/require-env.js";
69
+ import { FileLimits, limitedFileStore } from "./files/limits.js";
69
70
  import { LocalFilesOptions, localFileStore, localFiles } from "./files/local-files.js";
71
+ import { S3Bucket, S3FilesOptions, S3ListPage, S3Object, s3FileStore, s3Files } from "./files/s3-files.js";
70
72
  import { FileCleanupInput, createFileCleanup } from "./files/file-cleanup.js";
71
73
  import { FileObject } from "./files/present.js";
72
74
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
@@ -75,4 +77,4 @@ import { FatalJobError } from "./jobs/run-job.js";
75
77
  import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
76
78
  import { jobsDrainApp } from "./jobs/drain-route.js";
77
79
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
78
- export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FileCleanupInput, type FileObject, type FileProvider, type FileStore, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSource, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MemberInput, MissingCredentials, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type SignInPolicy, type SignInRule, type SignatureOptions, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, listOrganizations, listUsers, localFileStore, localFiles, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, streamChatCompletion, subscription, unauthorized, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
80
+ export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileStore, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSource, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MemberInput, MissingCredentials, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type SignInPolicy, type SignInRule, type SignatureOptions, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, streamChatCompletion, subscription, unauthorized, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
package/dist/index.js CHANGED
@@ -31,7 +31,9 @@ import { newWebhookSecret } from "./webhooks/standard-webhooks.js";
31
31
  import { subscription } from "./webhooks/subscription.js";
32
32
  import { errorReportingSettings, errorReportingVariable } from "./error-reporting/error-reporting-settings.js";
33
33
  import { storageCleanupSchedule } from "./files/cleanup-schedule.js";
34
+ import { limitedFileStore } from "./files/limits.js";
34
35
  import { localFileStore, localFiles } from "./files/local-files.js";
36
+ import { s3FileStore, s3Files } from "./files/s3-files.js";
35
37
  import { pluginOptions, pluginsFor, pluginsVariable } from "./plugins/plugin-settings.js";
36
38
  import { runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
37
39
  import { checkSourceNames, sourceOptions, sourcesFor, sourcesVariable } from "./sources/source-settings.js";
@@ -56,4 +58,4 @@ import { createFileCleanup } from "./files/file-cleanup.js";
56
58
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
57
59
  import { startJobs } from "./jobs/runtime.js";
58
60
  import { jobsDrainApp } from "./jobs/drain-route.js";
59
- export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, listOrganizations, listUsers, localFileStore, localFiles, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, streamChatCompletion, subscription, unauthorized, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
61
+ export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, streamChatCompletion, subscription, unauthorized, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.12.16",
3
+ "version": "0.12.18",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
- "@protobase/layout": "0.12.16",
34
- "@protobase/query": "0.12.16",
35
- "@protobase/schema": "0.12.16",
33
+ "@protobase/layout": "0.12.18",
34
+ "@protobase/query": "0.12.18",
35
+ "@protobase/schema": "0.12.18",
36
36
  "@scalar/hono-api-reference": "^0.12.10",
37
37
  "@sentry/core": "^11.7.0",
38
38
  "@simplewebauthn/server": "^13.3.1",