@protobase/server 0.30.0 → 0.30.1

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.
@@ -15,7 +15,7 @@ export type AdminOptions = {
15
15
  basePath?: string;
16
16
  /**
17
17
  * The app's publishable and secret keys, with which supabase-js calls the PostgREST-compatible `/rest/v1` and the
18
- * Realtime-compatible `/realtime/v1` (see `ApiKeysOptions`); default `PROTOBASE_PUBLISHABLE_KEY` and
18
+ * Realtime-compatible `/realtime/v1` and the Storage-compatible `/storage/v1` (see `ApiKeysOptions`); default `PROTOBASE_PUBLISHABLE_KEY` and
19
19
  * `PROTOBASE_SECRET_KEY`. Without either, or with `false`, the app serves neither.
20
20
  */
21
21
  apiKeys?: ApiKeysOptions | false;
@@ -0,0 +1,27 @@
1
+ //#region src/api-keys/caller-settings.ts
2
+ /**
3
+ * The transaction settings a request admitted by key (`/rest/v1`, `/storage/v1`) runs with, as Supabase's PostgREST
4
+ * sets them: `role`, which is `SET LOCAL ROLE`, to `anon` or `authenticated`, and the caller's claims in `request.jwt.claims` and its user in
5
+ * `request.jwt.claim.sub`, which `auth.uid()`, `auth.role()` and `auth.jwt()` read. The `role` claim names the
6
+ * database role, as on Supabase, whatever the token's own says. The app's role, of the secret key, sets no role: the
7
+ * connection's own owns the tables. A signed-in user is the actor change capture records, when `actorSetting` is set.
8
+ */
9
+ const callerSettings = (caller, actorSetting) => {
10
+ const role = caller.role === "app" ? "service_role" : caller.role;
11
+ const sub = caller.role === "authenticated" ? caller.claims.sub : void 0;
12
+ const actor = sub === void 0 ? void 0 : {
13
+ id: sub,
14
+ roles: []
15
+ };
16
+ return {
17
+ ...caller.role !== "app" && { role: caller.role },
18
+ "request.jwt.claims": JSON.stringify({
19
+ ...caller.claims,
20
+ role
21
+ }),
22
+ ...sub !== void 0 && { "request.jwt.claim.sub": sub },
23
+ ...actor && actorSetting && { [actorSetting]: JSON.stringify(actor) }
24
+ };
25
+ };
26
+ //#endregion
27
+ export { callerSettings };
@@ -72,7 +72,7 @@ export type CreateAdminInput = {
72
72
  formsDifficulty?: Difficulty;
73
73
  /** @internal The users comments name, mention and mail, which tests fake; from `auth` otherwise. */
74
74
  commentUsers?: CommentUsers;
75
- /** @internal Verifies a user's token on `/rest/v1` and `/realtime/v1` and gives its claims, which tests fake; those `/auth/v1` signs otherwise. */
75
+ /** @internal Verifies a user's token on `/rest/v1`, `/realtime/v1` and `/storage/v1` and gives its claims, which tests fake; those `/auth/v1` signs otherwise. */
76
76
  verifyToken?: (token: string) => Promise<JWTPayload>;
77
77
  /**
78
78
  * The host's WebSocket upgrade, such as `@hono/node-server`'s, with which an app with keys and live resources serves
@@ -17,6 +17,7 @@ import { apiKeyCaller } from "./api-keys/resolve-caller.js";
17
17
  import { jwtVerifier } from "./auth/jwt.js";
18
18
  import { realtimeApp } from "./realtime/realtime-app.js";
19
19
  import { restRoutes } from "./rest/rest-routes.js";
20
+ import { storageRoutes } from "./storage/storage-routes.js";
20
21
  import { readAppName } from "./app-name.js";
21
22
  import { goTruePath } from "./gotrue/keys.js";
22
23
  import { authBasePath } from "./better-auth/auth-base-path.js";
@@ -139,11 +140,13 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
139
140
  pages: pageModels.map((entry) => entry.name)
140
141
  });
141
142
  const apiKeys = apiKeySettings(options.apiKeys, globalThis.process?.env ?? {});
143
+ const servesKeys = Boolean(apiKeys.publishable || apiKeys.secret);
142
144
  const files = createFilesRuntime({
143
145
  ...filesOptions && { options: filesOptions },
144
146
  env: globalThis.process?.env ?? {},
145
147
  models: registry.entries.map((entry) => entry.model),
146
- systemPath
148
+ systemPath,
149
+ storage: servesKeys
147
150
  });
148
151
  const table = webhooksOptions && webhookTable(webhooksOptions, auth);
149
152
  const withAi = withAiFields || hasAiActions(viewModels);
@@ -286,7 +289,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
286
289
  report
287
290
  });
288
291
  if (appModels.some((model) => model.live)) app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
289
- if (apiKeys.publishable || apiKeys.secret) {
292
+ if (servesKeys) {
290
293
  const verify = verifyToken ?? (auth ? jwtVerifier(goTrueTokens({ auth })) : () => Promise.reject(unauthorized("This app signs no user tokens")));
291
294
  const caller = apiKeyCaller({
292
295
  keys: apiKeys,
@@ -304,6 +307,21 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
304
307
  caller,
305
308
  report
306
309
  }));
310
+ if (files?.storage) {
311
+ const storage = {
312
+ db,
313
+ statementTimeoutMs: deps.statementTimeoutMs,
314
+ ...deps.actorSetting && { actorSetting: deps.actorSetting },
315
+ store: files.providers[files.storage.provider].store,
316
+ signer: files.signer,
317
+ maxBytes: files.storage.maxBytes,
318
+ caller
319
+ };
320
+ app.route("/storage/v1", storageRoutes({
321
+ deps: storage,
322
+ report
323
+ }));
324
+ }
307
325
  }
308
326
  app.route(basePath, api);
309
327
  app.route(`${deps.systemPath}/meta`, metaApp);
@@ -7,6 +7,7 @@ import { fetchRecord } from "../records.js";
7
7
  import { readTransaction } from "../transactions.js";
8
8
  //#region src/files/download.ts
9
9
  const gone = () => notFound("There is no such file");
10
+ /** One `bytes=start-end` range, inclusive; `undefined` for none, `false` for one that cannot be served. */
10
11
  const rangeOf = (header, size) => {
11
12
  if (!header) return void 0;
12
13
  const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
@@ -106,4 +107,4 @@ const serveFile = async (deps, request, provider, path) => {
106
107
  });
107
108
  };
108
109
  //#endregion
109
- export { serveFile };
110
+ export { rangeOf, serveFile };
@@ -22,5 +22,14 @@ export type FilesOptions = {
22
22
  * public files from: `['images.example.com']`. Images from any other host load with `referrerPolicy="no-referrer"`.
23
23
  */
24
24
  trustedHosts?: string[];
25
+ /**
26
+ * Where `supabase.storage` keeps the bytes of its objects, which the app serves at `/storage/v1` when it has an API key
27
+ * (see `apiKeys`): `provider` names one of the providers, default `private`, and `maxFileSize` caps every upload, as
28
+ * `50 MB` (the default) or a number of bytes; a bucket's own limit can only be lower. `false` serves no `/storage/v1`.
29
+ */
30
+ storage?: {
31
+ provider?: string;
32
+ maxFileSize?: number | string;
33
+ } | false;
25
34
  };
26
35
  //#endregion
@@ -3,19 +3,25 @@ import { storageCleanupSchedule } from "./cleanup-schedule.js";
3
3
  import { resolveProviders, retentionOf } from "./providers.js";
4
4
  import { createSigner } from "./signer.js";
5
5
  import { trustedImageHosts } from "./trusted-hosts.js";
6
+ import { parseSize } from "@protobase/schema/internal";
6
7
  //#region src/files/runtime.ts
7
8
  /** How long an upload ticket stays usable, so a form left open still saves. */
8
9
  const ticketSeconds = 86400;
9
10
  const imageFields = (models) => models.flatMap((model) => Object.values(model.fields).flatMap(({ name, file }) => file && (file.variants || file.original || file.placeholder !== void 0) ? [`${model.name}.${name}`] : []));
10
11
  /**
11
- * The runtime, when a resource has a file field or the config names providers. It fails at startup when a field's
12
+ * The runtime, when a resource has a file field, the config names providers or the app serves `/storage/v1`. It fails at startup when a field's
12
13
  * provider is not configured, nothing signs uploads, or a field makes image variants or placeholders without sharp.
13
14
  */
14
- const createFilesRuntime = ({ options = {}, env, models, systemPath }) => {
15
- const used = [...new Set(models.flatMap((model) => Object.values(model.fields).flatMap((field) => field.file ? [field.file.provider] : [])))];
15
+ const createFilesRuntime = ({ options = {}, env, models, systemPath, storage: servesStorage = false }) => {
16
+ const storage = servesStorage && options.storage !== false ? {
17
+ provider: options.storage?.provider ?? "private",
18
+ maxBytes: parseSize(options.storage?.maxFileSize ?? "50 MB")
19
+ } : void 0;
20
+ const fieldProviders = models.flatMap((model) => Object.values(model.fields).flatMap((field) => field.file ? [field.file.provider] : []));
21
+ const used = [.../* @__PURE__ */ new Set([...fieldProviders, ...storage ? [storage.provider] : []])];
16
22
  if (used.length === 0 && !options.providers) return void 0;
17
23
  const secret = options.secret ?? env.PROTOBASE_FILES_SECRET ?? env.BETTER_AUTH_SECRET;
18
- if (!secret) throw new Error("File fields need a secret to sign uploads: set BETTER_AUTH_SECRET, PROTOBASE_FILES_SECRET or files.secret");
24
+ if (!secret) throw new Error("File fields and /storage/v1 need a secret to sign uploads: set BETTER_AUTH_SECRET, PROTOBASE_FILES_SECRET or files.secret");
19
25
  const images = imageFields(models);
20
26
  if (images.length > 0 && !options.sharp) throw new Error(`${images.join(", ")} make image variants or placeholders, which need sharp: add it to the project (bun add sharp), then import sharp from 'sharp' in the config and pass files: { sharp }`);
21
27
  const providers = resolveProviders(options, env, used);
@@ -28,7 +34,8 @@ const createFilesRuntime = ({ options = {}, env, models, systemPath }) => {
28
34
  path: `${systemPath}/files`,
29
35
  columns: fileColumns(models),
30
36
  trustedHosts: trustedImageHosts(providers, options.trustedHosts),
31
- ...options.sharp && { sharp: options.sharp }
37
+ ...options.sharp && { sharp: options.sharp },
38
+ ...storage && { storage }
32
39
  };
33
40
  };
34
41
  /** Runs the scheduled deletes that are due (see `runCleanup`). */
@@ -0,0 +1,78 @@
1
+ import { isDatabaseError } from "../error-handler.js";
2
+ import { deleteBucket, findBucket, insertBucket, listBuckets, updateBucket } from "./bucket-sql.js";
3
+ import { bucketNotEmpty, bucketNotFound, invalidRequest } from "./storage-error.js";
4
+ import { checkedBucketId, objectKey } from "./object-key.js";
5
+ import { deleteBucketObjects } from "./object-sql.js";
6
+ import { asCaller } from "./storage-db.js";
7
+ import { jsonBody } from "./storage-context.js";
8
+ import { ownerOf } from "./write-object.js";
9
+ import { Hono } from "hono";
10
+ import { parseSize } from "@protobase/schema/internal";
11
+ //#region src/storage/bucket-routes.ts
12
+ const bucketJson = (bucket) => ({
13
+ ...bucket,
14
+ type: "STANDARD"
15
+ });
16
+ const sizeLimit = (value) => {
17
+ if (value === null) return null;
18
+ if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return value;
19
+ if (typeof value === "string" && /^\s*\d+(\.\d+)?\s*(b|kb|mb|gb)?\s*$/i.test(value)) return parseSize(value);
20
+ throw invalidRequest("file_size_limit must be a number of bytes or a size such as 1MB");
21
+ };
22
+ const bucketWrite = (body) => {
23
+ const types = body.allowed_mime_types;
24
+ if (types !== void 0 && types !== null && !(Array.isArray(types) && types.every((type) => typeof type === "string"))) throw invalidRequest("allowed_mime_types must be a list of media types");
25
+ if (body.public !== void 0 && typeof body.public !== "boolean") throw invalidRequest("public must be true or false");
26
+ return {
27
+ ...body.public !== void 0 && { public: body.public },
28
+ ...body.file_size_limit !== void 0 && { file_size_limit: sizeLimit(body.file_size_limit) },
29
+ ...types !== void 0 && { allowed_mime_types: types }
30
+ };
31
+ };
32
+ /** `supabase.storage`'s bucket calls, each as the caller, so the restored `storage.buckets` policies decide. */
33
+ const bucketRoutes = (deps) => {
34
+ const app = new Hono();
35
+ app.get("/bucket", async (c) => c.json((await asCaller(deps, await deps.caller(c.req.raw), true, listBuckets)).map(bucketJson)));
36
+ app.get("/bucket/:id", async (c) => {
37
+ const id = checkedBucketId(c.req.param("id"));
38
+ const bucket = await asCaller(deps, await deps.caller(c.req.raw), true, (trx) => findBucket(trx, id));
39
+ if (!bucket) throw bucketNotFound();
40
+ return c.json(bucketJson(bucket));
41
+ });
42
+ app.post("/bucket", async (c) => {
43
+ const body = await jsonBody(c);
44
+ const name = checkedBucketId(typeof body.name === "string" ? body.name : "");
45
+ const id = typeof body.id === "string" ? checkedBucketId(body.id) : name;
46
+ const caller = await deps.caller(c.req.raw);
47
+ await asCaller(deps, caller, false, (trx) => insertBucket(trx, {
48
+ id,
49
+ name,
50
+ owner: ownerOf(caller),
51
+ ...bucketWrite(body)
52
+ }));
53
+ return c.json({ name: id });
54
+ });
55
+ app.put("/bucket/:id", async (c) => {
56
+ const id = checkedBucketId(c.req.param("id"));
57
+ const write = bucketWrite(await jsonBody(c));
58
+ if (!await asCaller(deps, await deps.caller(c.req.raw), false, (trx) => updateBucket(trx, id, write))) throw bucketNotFound();
59
+ return c.json({ message: "Successfully updated" });
60
+ });
61
+ app.post("/bucket/:id/empty", async (c) => {
62
+ const id = checkedBucketId(c.req.param("id"));
63
+ const deleted = await asCaller(deps, await deps.caller(c.req.raw), false, (trx) => deleteBucketObjects(trx, id));
64
+ for (const row of deleted) await deps.store.delete(await objectKey(row.bucket_id, row.name, row.version));
65
+ return c.json({ message: "Successfully emptied" });
66
+ });
67
+ app.delete("/bucket/:id", async (c) => {
68
+ const id = checkedBucketId(c.req.param("id"));
69
+ if (!await asCaller(deps, await deps.caller(c.req.raw), false, (trx) => deleteBucket(trx, id)).catch((error) => {
70
+ if (isDatabaseError(error) && error.code === "23503") throw bucketNotEmpty();
71
+ throw error;
72
+ })) throw bucketNotFound();
73
+ return c.json({ message: "Successfully deleted" });
74
+ });
75
+ return app;
76
+ };
77
+ //#endregion
78
+ export { bucketRoutes };
@@ -0,0 +1,29 @@
1
+ import { sql } from "kysely";
2
+ //#region src/storage/bucket-sql.ts
3
+ const columns = sql`id, name, owner, owner_id, public, file_size_limit::float8 as file_size_limit, allowed_mime_types, created_at, updated_at`;
4
+ /** The bucket `id`, as the transaction's role may see it. */
5
+ const findBucket = async (trx, id) => {
6
+ const { rows } = await sql`select ${columns} from storage.buckets where id = ${id}`.execute(trx);
7
+ return rows[0];
8
+ };
9
+ /** Every bucket the transaction's role may see, by name. */
10
+ const listBuckets = async (trx) => (await sql`select ${columns} from storage.buckets order by name collate "C"`.execute(trx)).rows;
11
+ /** Adds a bucket; RLS refuses it with 42501, a taken id or name with 23505. */
12
+ const insertBucket = async (trx, bucket) => {
13
+ await sql`
14
+ insert into storage.buckets (id, name, owner_id, public, file_size_limit, allowed_mime_types)
15
+ values (${bucket.id}, ${bucket.name}, ${bucket.owner}, ${bucket.public ?? false}, ${bucket.file_size_limit ?? null}, ${bucket.allowed_mime_types ?? null})`.execute(trx);
16
+ };
17
+ /** Changes the bucket `id`; whether the transaction's role could. */
18
+ const updateBucket = async (trx, id, write) => {
19
+ const sets = Object.entries(write).filter(([, value]) => value !== void 0).map(([column, value]) => sql`${sql.ref(column)} = ${value}`);
20
+ if (sets.length === 0) return await findBucket(trx, id) !== void 0;
21
+ return (await sql`update storage.buckets set ${sql.join(sets)} where id = ${id}`.execute(trx)).numAffectedRows === 1n;
22
+ };
23
+ /** Deletes the bucket `id`; whether the transaction's role could. A bucket that still has objects fails with 23503. */
24
+ const deleteBucket = async (trx, id) => {
25
+ await sql`select set_config('storage.allow_delete_query', 'true', true)`.execute(trx);
26
+ return (await sql`delete from storage.buckets where id = ${id}`.execute(trx)).numAffectedRows === 1n;
27
+ };
28
+ //#endregion
29
+ export { deleteBucket, findBucket, insertBucket, listBuckets, updateBucket };
@@ -0,0 +1,29 @@
1
+ import { sql } from "kysely";
2
+ //#region src/storage/list-sql.ts
3
+ const likeEscaped = (text) => text.replace(/[\\%_]/g, (char) => `\\${char}`);
4
+ /**
5
+ * One level of `bucket` below the folder `prefix` (`''` for the top, else ending in `/`), as the transaction's role may
6
+ * see it: the objects directly in it and the folders that hold objects deeper down, whose names start with `search`
7
+ * (case-insensitive). Folders sort with the objects by name; by any other column they come first, as in Supabase.
8
+ */
9
+ const listFolder = async (trx, bucket, query) => {
10
+ const start = [...query.prefix].length + 1;
11
+ const order = sql.raw(query.order === "desc" ? "desc" : "asc");
12
+ const { rows } = await sql`
13
+ with level as (
14
+ select split_part(substr(name, ${start}), '/', 1) as entry, strpos(substr(name, ${start}), '/') > 0 as folder, id, updated_at, created_at, last_accessed_at, metadata
15
+ from storage.objects
16
+ where bucket_id = ${bucket} and name like ${`${likeEscaped(query.prefix)}%`} and substr(name, ${start}) ilike ${`${likeEscaped(query.search)}%`}
17
+ ), entries as (
18
+ select distinct on (entry) entry as name, folder, case when folder then null else id end as id,
19
+ case when folder then null else updated_at end as updated_at, case when folder then null else created_at end as created_at,
20
+ case when folder then null else last_accessed_at end as last_accessed_at, case when folder then null else metadata end as metadata
21
+ from level order by entry, folder desc
22
+ )
23
+ select name, id, updated_at, created_at, last_accessed_at, metadata from entries
24
+ order by ${query.column === "name" ? sql`name collate "C" ${order}` : sql`folder desc, ${sql.ref(query.column)} ${order} nulls last, name collate "C"`}
25
+ limit ${query.limit} offset ${query.offset}`.execute(trx);
26
+ return rows;
27
+ };
28
+ //#endregion
29
+ export { listFolder };
@@ -0,0 +1,63 @@
1
+ import { accessDenied, alreadyExists, objectNotFound } from "./storage-error.js";
2
+ import { objectKey } from "./object-key.js";
3
+ import { findObject, renameObject } from "./object-sql.js";
4
+ import { asApp, asCaller, becomeCaller } from "./storage-db.js";
5
+ import { ownerOf, writeObject } from "./write-object.js";
6
+ //#region src/storage/move-copy.ts
7
+ /** The object as the caller may see it, else 404 `NoSuchKey`. */
8
+ const visibleObject = async (deps, caller, at) => {
9
+ const row = await asCaller(deps, caller, true, (trx) => findObject(trx, at.bucket, at.name));
10
+ if (!row) throw objectNotFound();
11
+ return row;
12
+ };
13
+ const copyBytes = async (store, row, key) => {
14
+ const body = await store.read(await objectKey(row.bucket_id, row.name, row.version));
15
+ if (!body) throw objectNotFound();
16
+ return store.put(key, body);
17
+ };
18
+ /**
19
+ * Copies an object the caller may see to `to`, with its type, cache control and metadata, as a new object the caller
20
+ * must be allowed to write there (see `writeObject`); `upsert` lets it replace one.
21
+ */
22
+ const copyObject = async (deps, caller, from, to, upsert) => {
23
+ const source = await visibleObject(deps, caller, from);
24
+ return writeObject(deps, {
25
+ ...to,
26
+ caller,
27
+ owner: ownerOf(caller),
28
+ upsert
29
+ }, {
30
+ type: source.metadata?.mimetype ?? "application/octet-stream",
31
+ cacheControl: source.metadata?.cacheControl ?? "max-age=3600",
32
+ userMetadata: source.user_metadata,
33
+ store: (key) => copyBytes(deps.store, source, key)
34
+ });
35
+ };
36
+ /**
37
+ * Moves an object the caller may see to `to`: its bytes are copied under a new version, then its row is renamed as the
38
+ * caller, so the update policies decide on the old and the new name. When that fails, the copy is deleted; once it
39
+ * commits, the old bytes are.
40
+ */
41
+ const moveObject = async (deps, caller, from, to) => {
42
+ const source = await visibleObject(deps, caller, from);
43
+ const version = crypto.randomUUID();
44
+ const key = await objectKey(to.bucket, to.name, version);
45
+ await copyBytes(deps.store, source, key);
46
+ await asApp(deps, async (trx) => {
47
+ if (!await findObject(trx, from.bucket, from.name, true)) throw objectNotFound();
48
+ if (await findObject(trx, to.bucket, to.name, true)) throw alreadyExists();
49
+ await becomeCaller(trx, deps, caller);
50
+ if (!await renameObject(trx, from, {
51
+ ...to,
52
+ version,
53
+ owner: ownerOf(caller)
54
+ })) throw accessDenied();
55
+ }).catch(async (error) => {
56
+ await deps.store.delete(key);
57
+ throw error;
58
+ });
59
+ await deps.store.delete(await objectKey(source.bucket_id, source.name, source.version));
60
+ return source.id;
61
+ };
62
+ //#endregion
63
+ export { copyObject, moveObject, visibleObject };
@@ -0,0 +1,47 @@
1
+ //#region src/storage/multipart.ts
2
+ const encoder = new TextEncoder();
3
+ const decoder = new TextDecoder();
4
+ const indexOf = (haystack, needle, from) => {
5
+ for (let at = haystack.indexOf(needle[0], from); at !== -1 && at + needle.length <= haystack.length; at = haystack.indexOf(needle[0], at + 1)) {
6
+ let index = 1;
7
+ while (index < needle.length && haystack[at + index] === needle[index]) index += 1;
8
+ if (index === needle.length) return at;
9
+ }
10
+ return -1;
11
+ };
12
+ /** The boundary of a `multipart/form-data` Content-Type, else `undefined`. */
13
+ const boundaryOf = (contentType) => /;\s*boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType)?.slice(1).find(Boolean);
14
+ const headerValue = (headers, name) => new RegExp(`^${name}:\\s*(.*)$`, "im").exec(headers)?.[1]?.trim();
15
+ const parameter = (disposition, name) => new RegExp(`;\\s*${name}="([^"]*)"`, "i").exec(disposition)?.[1];
16
+ /**
17
+ * The parts of a whole `multipart/form-data` body, every one kept, also a file part with an empty field name, as
18
+ * storage-js sends a `Blob`: Bun's `formData()` drops it. Undefined when the body does not parse.
19
+ */
20
+ const multipartParts = (bytes, boundary) => {
21
+ const delimiter = encoder.encode(`--${boundary}`);
22
+ const next = encoder.encode(`\r\n--${boundary}`);
23
+ const parts = [];
24
+ let at = indexOf(bytes, delimiter, 0);
25
+ if (at === -1) return void 0;
26
+ at += delimiter.length;
27
+ while (decoder.decode(bytes.subarray(at, at + 2)) !== "--") {
28
+ if (decoder.decode(bytes.subarray(at, at + 2)) !== "\r\n") return void 0;
29
+ const headersEnd = indexOf(bytes, encoder.encode("\r\n\r\n"), at + 2);
30
+ const end = headersEnd === -1 ? -1 : indexOf(bytes, next, headersEnd + 4);
31
+ if (end === -1) return void 0;
32
+ const headers = decoder.decode(bytes.subarray(at + 2, headersEnd));
33
+ const disposition = headerValue(headers, "content-disposition") ?? "";
34
+ const filename = parameter(disposition, "filename");
35
+ const type = headerValue(headers, "content-type");
36
+ parts.push({
37
+ name: parameter(disposition, "name") ?? "",
38
+ ...filename !== void 0 && { filename },
39
+ ...type && { type },
40
+ body: bytes.subarray(headersEnd + 4, end)
41
+ });
42
+ at = end + next.length;
43
+ }
44
+ return parts;
45
+ };
46
+ //#endregion
47
+ export { boundaryOf, multipartParts };
@@ -0,0 +1,75 @@
1
+ import { invalidRequest, tooLarge } from "./storage-error.js";
2
+ import { boundaryOf, multipartParts } from "./multipart.js";
3
+ //#region src/storage/object-body.ts
4
+ const defaultType = "text/plain;charset=UTF-8";
5
+ const defaultCacheControl = "max-age=3600";
6
+ const parsedMetadata = (text) => {
7
+ if (text === null) return null;
8
+ try {
9
+ const value = JSON.parse(text);
10
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) return value;
11
+ } catch (error) {
12
+ if (!(error instanceof SyntaxError)) throw error;
13
+ }
14
+ throw invalidRequest("The metadata is not a JSON object");
15
+ };
16
+ const fromBase64 = (text) => {
17
+ try {
18
+ return new TextDecoder().decode(Uint8Array.from(atob(text), (char) => char.charCodeAt(0)));
19
+ } catch (error) {
20
+ if (error instanceof DOMException && error.name === "InvalidCharacterError") throw invalidRequest("The x-metadata header is not base64");
21
+ throw error;
22
+ }
23
+ };
24
+ /** `body`, failing with 413 `EntityTooLarge` once it passes `maxBytes`. */
25
+ const limitedBody = (body, maxBytes) => {
26
+ let size = 0;
27
+ return body.pipeThrough(new TransformStream({ transform: (chunk, controller) => {
28
+ size += chunk.length;
29
+ if (size > maxBytes) throw tooLarge();
30
+ controller.enqueue(chunk);
31
+ } }));
32
+ };
33
+ /**
34
+ * The object of an upload request: a raw body with its Content-Type, `Cache-Control` and the base64 JSON of
35
+ * `x-metadata`, or the file part of a `multipart/form-data` body with its type and the `cacheControl` and `metadata`
36
+ * fields, as storage-js sends a `Blob` or `File` (see `multipartParts`). A multipart body is read whole, after its length is checked; a raw
37
+ * body streams. Past `maxBytes` it is 413 `EntityTooLarge`.
38
+ */
39
+ const incomingObject = async (request, maxBytes) => {
40
+ const header = request.headers.get("content-length");
41
+ const length = header === null ? void 0 : Number(header);
42
+ const contentType = request.headers.get("content-type") ?? defaultType;
43
+ if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
44
+ if (length !== void 0 && length > maxBytes) throw tooLarge();
45
+ const metadata = request.headers.get("x-metadata");
46
+ return {
47
+ body: limitedBody(request.body ?? new Blob([]).stream(), maxBytes),
48
+ type: contentType,
49
+ ...length !== void 0 && { length },
50
+ cacheControl: request.headers.get("cache-control") ?? defaultCacheControl,
51
+ userMetadata: parsedMetadata(metadata === null ? null : fromBase64(metadata))
52
+ };
53
+ }
54
+ if (length !== void 0 && length > maxBytes + 65536) throw tooLarge();
55
+ const boundary = boundaryOf(contentType);
56
+ const parts = boundary === void 0 ? void 0 : multipartParts(new Uint8Array(await request.arrayBuffer()), boundary);
57
+ if (!parts) throw invalidRequest("The multipart body does not parse");
58
+ const file = parts.find((part) => part.filename !== void 0);
59
+ if (!file) throw invalidRequest("The multipart body has no file");
60
+ if (file.body.length > maxBytes) throw tooLarge();
61
+ const field = (name) => {
62
+ const part = parts.find((one) => one.name === name && one.filename === void 0);
63
+ return part ? new TextDecoder().decode(part.body) : null;
64
+ };
65
+ const cacheControl = field("cacheControl");
66
+ return {
67
+ body: new Blob([file.body]).stream(),
68
+ type: file.type ?? defaultType,
69
+ length: file.body.length,
70
+ cacheControl: cacheControl === null ? defaultCacheControl : `max-age=${cacheControl}`,
71
+ userMetadata: parsedMetadata(field("metadata"))
72
+ };
73
+ };
74
+ //#endregion
75
+ export { incomingObject, limitedBody };
@@ -0,0 +1,32 @@
1
+ import { invalidBucketName, invalidKey } from "./storage-error.js";
2
+ //#region src/storage/object-key.ts
3
+ const bucketPattern = /^[\w!\-.*'() &$@=;:+,?]{1,100}$/;
4
+ const maxNameBytes = 1024;
5
+ const controlCharacters = /[\u0000-\u001f\u007f]/;
6
+ /** A bucket id as Supabase Storage accepts it, else 400 `InvalidBucketName`. */
7
+ const checkedBucketId = (id) => {
8
+ if (!bucketPattern.test(id)) throw invalidBucketName();
9
+ return id;
10
+ };
11
+ /**
12
+ * An object name as Supabase Storage accepts it: not empty, at most 1024 bytes, no control characters, no empty folder
13
+ * and no `.` or `..` folder. Else 400 `InvalidKey`.
14
+ */
15
+ const checkedName = (name) => {
16
+ const parts = name.split("/");
17
+ if (!(name !== "" && new TextEncoder().encode(name).length <= maxNameBytes && !controlCharacters.test(name) && parts.every((part) => part !== "" && part !== "." && part !== ".."))) throw invalidKey(name);
18
+ return name;
19
+ };
20
+ const hex = (bytes) => [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
21
+ /**
22
+ * Where an object's bytes are kept in the storage provider: `storage/<sha-256 of bucket and name>/<version>`. Names may
23
+ * hold any character, which no provider takes in a path; the hash takes every one, and the version keeps an upload apart
24
+ * from the one it replaces until the database says which is current. A row without a version, as an import may leave
25
+ * it, is at `unversioned`.
26
+ */
27
+ const objectKey = async (bucket, name, version) => {
28
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([bucket, name])));
29
+ return `storage/${hex(digest)}/${version ?? "unversioned"}`;
30
+ };
31
+ //#endregion
32
+ export { checkedBucketId, checkedName, objectKey };
@@ -0,0 +1,130 @@
1
+ import { invalidRequest, objectNotFound } from "./storage-error.js";
2
+ import { checkedBucketId, checkedName, objectKey } from "./object-key.js";
3
+ import { deleteObjects, findPublicObject } from "./object-sql.js";
4
+ import { asApp, asCaller } from "./storage-db.js";
5
+ import { addressOf, jsonBody, stringField } from "./storage-context.js";
6
+ import { ownerOf } from "./write-object.js";
7
+ import { listFolder } from "./list-sql.js";
8
+ import { serveObject } from "./serve-object.js";
9
+ import { copyObject, moveObject, visibleObject } from "./move-copy.js";
10
+ import { uploadObject } from "./upload-object.js";
11
+ import { Hono } from "hono";
12
+ //#region src/storage/object-routes.ts
13
+ const sortColumns = /* @__PURE__ */ new Set([
14
+ "name",
15
+ "updated_at",
16
+ "created_at",
17
+ "last_accessed_at"
18
+ ]);
19
+ const listQuery = (body) => {
20
+ const prefix = typeof body.prefix === "string" ? body.prefix : "";
21
+ const sortBy = typeof body.sortBy === "object" && body.sortBy !== null ? body.sortBy : {};
22
+ const column = typeof sortBy.column === "string" && sortColumns.has(sortBy.column) ? sortBy.column : "name";
23
+ const count = (value, fallback) => Number.isSafeInteger(value) && value >= 0 ? value : fallback;
24
+ return {
25
+ prefix: prefix === "" || prefix.endsWith("/") ? prefix : `${prefix}/`,
26
+ search: typeof body.search === "string" ? body.search : "",
27
+ limit: Math.min(count(body.limit, 100), 1e3),
28
+ offset: count(body.offset, 0),
29
+ column,
30
+ order: sortBy.order === "desc" ? "desc" : "asc"
31
+ };
32
+ };
33
+ /** The routes of objects that the caller's role reaches: upload, download, delete, info, list, move and copy, and public objects. */
34
+ const objectRoutes = (deps) => {
35
+ const app = new Hono();
36
+ app.get("/object/public/:bucket/:name{.+}", async (c) => {
37
+ const { bucket, name } = addressOf(c);
38
+ const row = await asApp(deps, (trx) => findPublicObject(trx, bucket, name));
39
+ if (!row) throw objectNotFound();
40
+ return serveObject(deps.store, row, c.req.raw);
41
+ });
42
+ app.post("/object/list/:bucket", async (c) => {
43
+ const bucket = checkedBucketId(c.req.param("bucket"));
44
+ const query = listQuery(await jsonBody(c));
45
+ const caller = await deps.caller(c.req.raw);
46
+ return c.json(await asCaller(deps, caller, true, (trx) => listFolder(trx, bucket, query)));
47
+ });
48
+ app.post("/object/move", async (c) => {
49
+ const body = await jsonBody(c);
50
+ const caller = await deps.caller(c.req.raw);
51
+ const from = {
52
+ bucket: checkedBucketId(stringField(body, "bucketId")),
53
+ name: checkedName(stringField(body, "sourceKey"))
54
+ };
55
+ const to = {
56
+ bucket: checkedBucketId(typeof body.destinationBucket === "string" ? body.destinationBucket : from.bucket),
57
+ name: checkedName(stringField(body, "destinationKey"))
58
+ };
59
+ const id = await moveObject(deps, caller, from, to);
60
+ return c.json({
61
+ message: "Successfully moved",
62
+ Id: id,
63
+ Key: to.name
64
+ });
65
+ });
66
+ app.post("/object/copy", async (c) => {
67
+ const body = await jsonBody(c);
68
+ const caller = await deps.caller(c.req.raw);
69
+ const from = {
70
+ bucket: checkedBucketId(stringField(body, "bucketId")),
71
+ name: checkedName(stringField(body, "sourceKey"))
72
+ };
73
+ const to = {
74
+ bucket: checkedBucketId(typeof body.destinationBucket === "string" ? body.destinationBucket : from.bucket),
75
+ name: checkedName(stringField(body, "destinationKey"))
76
+ };
77
+ const { id } = await copyObject(deps, caller, from, to, c.req.header("x-upsert") === "true");
78
+ return c.json({
79
+ Id: id,
80
+ Key: `${to.bucket}/${to.name}`
81
+ });
82
+ });
83
+ app.get("/object/info/:bucket/:name{.+}", async (c) => {
84
+ const row = await visibleObject(deps, await deps.caller(c.req.raw), addressOf(c));
85
+ return c.json({
86
+ id: row.id,
87
+ name: row.name,
88
+ version: row.version,
89
+ bucket_id: row.bucket_id,
90
+ size: row.metadata?.size ?? null,
91
+ content_type: row.metadata?.mimetype ?? null,
92
+ cache_control: row.metadata?.cacheControl ?? null,
93
+ etag: row.metadata?.eTag ?? null,
94
+ metadata: row.user_metadata,
95
+ last_modified: row.updated_at,
96
+ created_at: row.created_at
97
+ });
98
+ });
99
+ for (const path of ["/object/authenticated/:bucket/:name{.+}", "/object/:bucket/:name{.+}"]) app.get(path, async (c) => serveObject(deps.store, await visibleObject(deps, await deps.caller(c.req.raw), addressOf(c)), c.req.raw));
100
+ app.on(["POST", "PUT"], "/object/:bucket/:name{.+}", async (c) => {
101
+ const caller = await deps.caller(c.req.raw);
102
+ const upsert = c.req.method === "PUT" || c.req.header("x-upsert") === "true";
103
+ return c.json(await uploadObject(deps, c.req.raw, {
104
+ ...addressOf(c),
105
+ caller,
106
+ owner: ownerOf(caller),
107
+ upsert
108
+ }));
109
+ });
110
+ app.delete("/object/:bucket", async (c) => {
111
+ const bucket = checkedBucketId(c.req.param("bucket"));
112
+ const prefixes = (await jsonBody(c)).prefixes;
113
+ if (!Array.isArray(prefixes) || !prefixes.every((name) => typeof name === "string")) throw invalidRequest("body must have required property 'prefixes'");
114
+ const caller = await deps.caller(c.req.raw);
115
+ const deleted = await asCaller(deps, caller, false, (trx) => deleteObjects(trx, bucket, prefixes));
116
+ for (const row of deleted) await deps.store.delete(await objectKey(row.bucket_id, row.name, row.version));
117
+ return c.json(deleted);
118
+ });
119
+ app.delete("/object/:bucket/:name{.+}", async (c) => {
120
+ const { bucket, name } = addressOf(c);
121
+ const caller = await deps.caller(c.req.raw);
122
+ const [deleted] = await asCaller(deps, caller, false, (trx) => deleteObjects(trx, bucket, [name]));
123
+ if (!deleted) throw objectNotFound();
124
+ await deps.store.delete(await objectKey(deleted.bucket_id, deleted.name, deleted.version));
125
+ return c.json({ message: "Successfully deleted" });
126
+ });
127
+ return app;
128
+ };
129
+ //#endregion
130
+ export { objectRoutes };
@@ -0,0 +1,56 @@
1
+ import { sql } from "kysely";
2
+ //#region src/storage/object-sql.ts
3
+ const columns = sql`id, bucket_id, name, owner, owner_id, version, metadata, user_metadata, created_at, updated_at, last_accessed_at`;
4
+ const ownerUuid = (owner) => owner !== null && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(owner) ? owner : null;
5
+ /** The object `name` of `bucket`, as the transaction's role may see it; locked for the transaction when `lock`. */
6
+ const findObject = async (trx, bucket, name, lock = false) => {
7
+ const { rows } = await sql`select ${columns} from storage.objects where bucket_id = ${bucket} and name = ${name} ${lock ? sql`for update` : sql``}`.execute(trx);
8
+ return rows[0];
9
+ };
10
+ /** The object `name` of a public bucket, for anyone. */
11
+ const findPublicObject = async (trx, bucket, name) => {
12
+ const { rows } = await sql`
13
+ select ${columns} from storage.objects
14
+ where bucket_id = ${bucket} and name = ${name} and exists (select from storage.buckets where id = ${bucket} and public)`.execute(trx);
15
+ return rows[0];
16
+ };
17
+ /** Which of `names` in `bucket` the transaction's role may see. */
18
+ const visibleNames = async (trx, bucket, names) => (await sql`select name from storage.objects where bucket_id = ${bucket} and name = any(${names}::text[])`.execute(trx)).rows.map((row) => row.name);
19
+ /** Adds the object; RLS refuses it with 42501, an existing name with 23505. No `returning`, which would need a select policy too. */
20
+ const insertObject = async (trx, bucket, name, write) => {
21
+ const id = crypto.randomUUID();
22
+ await sql`
23
+ insert into storage.objects (id, bucket_id, name, owner, owner_id, version, metadata, user_metadata)
24
+ values (${id}, ${bucket}, ${name}, ${ownerUuid(write.owner)}, ${write.owner}, ${write.version}, ${JSON.stringify(write.metadata)}, ${write.user_metadata === null ? null : JSON.stringify(write.user_metadata)})`.execute(trx);
25
+ return id;
26
+ };
27
+ /** Replaces the object with `id`; whether the transaction's role could. */
28
+ const updateObject = async (trx, id, write) => {
29
+ return (await sql`
30
+ update storage.objects set owner = ${ownerUuid(write.owner)}, owner_id = ${write.owner}, version = ${write.version},
31
+ metadata = ${JSON.stringify(write.metadata)}, user_metadata = ${write.user_metadata === null ? null : JSON.stringify(write.user_metadata)}
32
+ where id = ${id}`.execute(trx)).numAffectedRows === 1n;
33
+ };
34
+ /** Gives the object `name` a new bucket, name and version, as a move does; whether the transaction's role could. */
35
+ const renameObject = async (trx, from, to) => {
36
+ return (await sql`
37
+ update storage.objects set bucket_id = ${to.bucket}, name = ${to.name}, version = ${to.version}, owner = ${ownerUuid(to.owner)}, owner_id = ${to.owner}
38
+ where bucket_id = ${from.bucket} and name = ${from.name}`.execute(trx)).numAffectedRows === 1n;
39
+ };
40
+ /**
41
+ * Deletes the objects of `bucket` named in `names` that the transaction's role may delete, and gives them back. The
42
+ * restored schema refuses a delete unless `storage.allow_delete_query` is set, as Supabase Storage sets it.
43
+ */
44
+ const deleteObjects = async (trx, bucket, names) => {
45
+ await sql`select set_config('storage.allow_delete_query', 'true', true)`.execute(trx);
46
+ const { rows } = await sql`delete from storage.objects where bucket_id = ${bucket} and name = any(${names}::text[]) returning ${columns}`.execute(trx);
47
+ return rows;
48
+ };
49
+ /** Every object of `bucket` the transaction's role may delete, deleted and given back, as emptying a bucket does. */
50
+ const deleteBucketObjects = async (trx, bucket) => {
51
+ await sql`select set_config('storage.allow_delete_query', 'true', true)`.execute(trx);
52
+ const { rows } = await sql`delete from storage.objects where bucket_id = ${bucket} returning ${columns}`.execute(trx);
53
+ return rows;
54
+ };
55
+ //#endregion
56
+ export { deleteBucketObjects, deleteObjects, findObject, findPublicObject, insertObject, renameObject, updateObject, visibleNames };
@@ -0,0 +1,51 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ import "../files/media-types.js";
3
+ import { objectNotFound } from "./storage-error.js";
4
+ import { objectKey } from "./object-key.js";
5
+ import { rangeOf } from "../files/download.js";
6
+ //#region src/storage/serve-object.ts
7
+ const disposition = (download, name) => {
8
+ if (download === null) return {};
9
+ const fileName = download || name.slice(name.lastIndexOf("/") + 1);
10
+ return { "content-disposition": `attachment; filename="${fileName.replace(/[^\x20-\x7e]|["\\]/g, "_")}"; filename*=UTF-8''${encodeURIComponent(fileName)}` };
11
+ };
12
+ /**
13
+ * The bytes of an object, or the one range asked for, with the type, cache control and ETag it was uploaded with. The
14
+ * type is the uploader's, as on Supabase, so nothing on the app's own origin may run: `nosniff` and a sandboxing CSP.
15
+ */
16
+ const serveObject = async (store, row, request) => {
17
+ const key = await objectKey(row.bucket_id, row.name, row.version);
18
+ const object = await store.head(key);
19
+ if (!object) throw objectNotFound();
20
+ const headers = {
21
+ "content-type": row.metadata?.mimetype ?? "application/octet-stream",
22
+ "cache-control": row.metadata?.cacheControl ?? "no-cache",
23
+ ...row.metadata?.eTag && { etag: row.metadata.eTag },
24
+ "last-modified": row.updated_at.toUTCString(),
25
+ "accept-ranges": "bytes",
26
+ "x-content-type-options": "nosniff",
27
+ "content-security-policy": "sandbox; default-src 'none'",
28
+ ...disposition(new URL(request.url).searchParams.get("download"), row.name)
29
+ };
30
+ const range = rangeOf(request.headers.get("range"), object.size);
31
+ if (range === false) throw new HttpProblem(416, "range-not-satisfiable", "Range Not Satisfiable", "The requested range is outside the object", {}, { "content-range": `bytes */${object.size}` });
32
+ const body = await store.read(key, range);
33
+ if (!body) throw objectNotFound();
34
+ if (!range) return new Response(body, {
35
+ status: 200,
36
+ headers: {
37
+ ...headers,
38
+ "content-length": String(object.size)
39
+ }
40
+ });
41
+ return new Response(body, {
42
+ status: 206,
43
+ headers: {
44
+ ...headers,
45
+ "content-length": String(range.end - range.start + 1),
46
+ "content-range": `bytes ${range.start}-${range.end}/${object.size}`
47
+ }
48
+ });
49
+ };
50
+ //#endregion
51
+ export { serveObject };
@@ -0,0 +1,121 @@
1
+ import { invalidRequest, objectNotFound } from "./storage-error.js";
2
+ import { checkedBucketId } from "./object-key.js";
3
+ import { findObject, visibleNames } from "./object-sql.js";
4
+ import { asApp, asCaller } from "./storage-db.js";
5
+ import { addressOf, jsonBody } from "./storage-context.js";
6
+ import { ownerOf, probeWrite } from "./write-object.js";
7
+ import { serveObject } from "./serve-object.js";
8
+ import { visibleObject } from "./move-copy.js";
9
+ import { uploadObject } from "./upload-object.js";
10
+ import { readGrant, signGrant } from "./storage-token.js";
11
+ import { Hono } from "hono";
12
+ //#region src/storage/signed-routes.ts
13
+ const uploadSeconds = 7200;
14
+ const expiresIn = (body) => {
15
+ const seconds = body.expiresIn;
16
+ if (typeof seconds !== "number" || !Number.isSafeInteger(seconds) || seconds < 1) throw invalidRequest("expiresIn must be a whole number of seconds, at least 1");
17
+ return Math.floor(Date.now() / 1e3) + seconds;
18
+ };
19
+ const queryToken = (request) => new URL(request.url).searchParams.get("token") ?? "";
20
+ /**
21
+ * Signed URLs: the caller signs a download of an object it may read, or an upload it may write, and anyone with the
22
+ * URL then reads or writes that one object until it expires, without a key.
23
+ */
24
+ const signedRoutes = (deps) => {
25
+ const app = new Hono();
26
+ app.post("/object/sign/:bucket/:name{.+}", async (c) => {
27
+ const { bucket, name } = addressOf(c);
28
+ const expires = expiresIn(await jsonBody(c));
29
+ await visibleObject(deps, await deps.caller(c.req.raw), {
30
+ bucket,
31
+ name
32
+ });
33
+ const token = await signGrant(deps.signer, "storage-download", {
34
+ bucket,
35
+ name,
36
+ expires
37
+ });
38
+ return c.json({ signedURL: `/object/sign/${bucket}/${name}?token=${token}` });
39
+ });
40
+ app.post("/object/sign/:bucket", async (c) => {
41
+ const bucket = checkedBucketId(c.req.param("bucket"));
42
+ const body = await jsonBody(c);
43
+ const expires = expiresIn(body);
44
+ if (!Array.isArray(body.paths) || !body.paths.every((path) => typeof path === "string")) throw invalidRequest("body must have required property 'paths'");
45
+ const paths = body.paths;
46
+ const caller = await deps.caller(c.req.raw);
47
+ const visible = new Set(await asCaller(deps, caller, true, (trx) => visibleNames(trx, bucket, paths)));
48
+ const signed = paths.map(async (path) => {
49
+ if (!visible.has(path)) return {
50
+ error: "Either the object does not exist or you do not have access to it",
51
+ path,
52
+ signedURL: null
53
+ };
54
+ const token = await signGrant(deps.signer, "storage-download", {
55
+ bucket,
56
+ name: path,
57
+ expires
58
+ });
59
+ return {
60
+ error: null,
61
+ path,
62
+ signedURL: `/object/sign/${bucket}/${path}?token=${token}`
63
+ };
64
+ });
65
+ return c.json(await Promise.all(signed));
66
+ });
67
+ app.get("/object/sign/:bucket/:name{.+}", async (c) => {
68
+ const { bucket, name } = addressOf(c);
69
+ await readGrant(deps.signer, "storage-download", queryToken(c.req.raw), {
70
+ bucket,
71
+ name
72
+ });
73
+ const row = await asApp(deps, (trx) => findObject(trx, bucket, name));
74
+ if (!row) throw objectNotFound();
75
+ return serveObject(deps.store, row, c.req.raw);
76
+ });
77
+ app.post("/object/upload/sign/:bucket/:name{.+}", async (c) => {
78
+ const { bucket, name } = addressOf(c);
79
+ const caller = await deps.caller(c.req.raw);
80
+ const upsert = c.req.header("x-upsert") === "true";
81
+ await probeWrite(deps, {
82
+ bucket,
83
+ name,
84
+ caller,
85
+ owner: ownerOf(caller),
86
+ upsert
87
+ }, c.req.header("content-type") ?? "application/octet-stream");
88
+ const token = await signGrant(deps.signer, "storage-upload", {
89
+ bucket,
90
+ name,
91
+ owner: ownerOf(caller),
92
+ upsert,
93
+ expires: Math.floor(Date.now() / 1e3) + uploadSeconds
94
+ });
95
+ return c.json({
96
+ url: `/object/upload/sign/${bucket}/${name}?token=${token}`,
97
+ token
98
+ });
99
+ });
100
+ app.put("/object/upload/sign/:bucket/:name{.+}", async (c) => {
101
+ const { bucket, name } = addressOf(c);
102
+ const grant = await readGrant(deps.signer, "storage-upload", queryToken(c.req.raw), {
103
+ bucket,
104
+ name
105
+ });
106
+ const { Key } = await uploadObject(deps, c.req.raw, {
107
+ bucket,
108
+ name,
109
+ caller: {
110
+ role: "app",
111
+ claims: { role: "service_role" }
112
+ },
113
+ owner: grant.owner,
114
+ upsert: grant.upsert
115
+ });
116
+ return c.json({ Key });
117
+ });
118
+ return app;
119
+ };
120
+ //#endregion
121
+ export { signedRoutes };
@@ -0,0 +1,25 @@
1
+ import { invalidRequest } from "./storage-error.js";
2
+ import { checkedBucketId, checkedName } from "./object-key.js";
3
+ //#region src/storage/storage-context.ts
4
+ /** The object a route's `:bucket` and `:name{.+}` address, both checked. */
5
+ const addressOf = (c) => ({
6
+ bucket: checkedBucketId(c.req.param("bucket")),
7
+ name: checkedName(c.req.param("name"))
8
+ });
9
+ /** The JSON object of a request's body, else 400. */
10
+ const jsonBody = async (c) => {
11
+ const body = await c.req.json().catch((error) => {
12
+ if (error instanceof SyntaxError) return void 0;
13
+ throw error;
14
+ });
15
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw invalidRequest("The body is not a JSON object");
16
+ return body;
17
+ };
18
+ /** A string field of a body, else 400 naming it. */
19
+ const stringField = (body, name) => {
20
+ const value = body[name];
21
+ if (typeof value !== "string") throw invalidRequest(`body must have required property '${name}'`);
22
+ return value;
23
+ };
24
+ //#endregion
25
+ export { addressOf, jsonBody, stringField };
@@ -0,0 +1,23 @@
1
+ import { readTransaction, writeTransaction } from "../transactions.js";
2
+ import { callerSettings } from "../api-keys/caller-settings.js";
3
+ import { sql } from "kysely";
4
+ //#region src/storage/storage-db.ts
5
+ /**
6
+ * Makes the rest of the transaction run as `caller`: its role and claims, as `/rest/v1` sets them (see
7
+ * `callerSettings`), so the restored `storage.objects` and `storage.buckets` policies decide what it reads and writes.
8
+ * What runs before it runs as the app's own role, which owns the tables.
9
+ */
10
+ const becomeCaller = async (trx, deps, caller) => {
11
+ const settings = Object.entries(callerSettings(caller, deps.actorSetting));
12
+ await sql`select ${sql.join(settings.map(([name, value]) => sql`set_config(${name}, ${value}, true)`))}`.execute(trx);
13
+ };
14
+ /** Runs `work` in one transaction as `caller`, read-only when `readOnly`. */
15
+ const asCaller = (deps, caller, readOnly, work) => {
16
+ const settings = callerSettings(caller, deps.actorSetting);
17
+ if (readOnly) return readTransaction(deps.db, deps.statementTimeoutMs, work, settings);
18
+ return writeTransaction(deps.db, deps.statementTimeoutMs, work, settings);
19
+ };
20
+ /** Runs `work` in one transaction as the app's own role, for what no caller decides: public objects, signed URLs. */
21
+ const asApp = (deps, work) => writeTransaction(deps.db, deps.statementTimeoutMs, work);
22
+ //#endregion
23
+ export { asApp, asCaller, becomeCaller };
@@ -0,0 +1,63 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ import { isDatabaseError } from "../error-handler.js";
3
+ //#region src/storage/storage-error.ts
4
+ /**
5
+ * An error as Supabase Storage sends it, which storage-js hands to the app as `error`: the HTTP status is 400 for any
6
+ * refusal and 500 for a failure, and `statusCode` in the body says what it was (`404`, `403`, `409`, …).
7
+ */
8
+ var StorageError = class extends Error {
9
+ statusCode;
10
+ code;
11
+ error;
12
+ constructor(statusCode, code, error, message) {
13
+ super(message);
14
+ this.statusCode = statusCode;
15
+ this.code = code;
16
+ this.error = error;
17
+ }
18
+ get status() {
19
+ return this.statusCode === 500 ? 500 : 400;
20
+ }
21
+ get body() {
22
+ return {
23
+ statusCode: String(this.statusCode),
24
+ code: this.code,
25
+ error: this.error,
26
+ message: this.message
27
+ };
28
+ }
29
+ };
30
+ const objectNotFound = () => new StorageError(404, "NoSuchKey", "not_found", "Object not found");
31
+ const bucketNotFound = () => new StorageError(404, "NoSuchBucket", "Bucket not found", "Bucket not found");
32
+ const accessDenied = (message = "new row violates row-level security policy") => new StorageError(403, "AccessDenied", "Unauthorized", message);
33
+ const alreadyExists = () => new StorageError(409, "Duplicate", "Duplicate", "The resource already exists");
34
+ const invalidRequest = (message) => new StorageError(400, "InvalidRequest", "InvalidRequest", message);
35
+ const invalidKey = (key) => new StorageError(400, "InvalidKey", "InvalidKey", `Invalid key: ${key}`);
36
+ const invalidBucketName = () => new StorageError(400, "InvalidBucketName", "Invalid Input", "Bucket name invalid");
37
+ const invalidSignature = () => new StorageError(400, "InvalidSignature", "InvalidSignature", "Invalid signature");
38
+ const expiredSignature = () => new StorageError(400, "ExpiredToken", "ExpiredToken", "The provided token has expired.");
39
+ const tooLarge = () => new StorageError(413, "EntityTooLarge", "Payload too large", "The object exceeded the maximum allowed size");
40
+ const mimeTypeRefused = (type) => new StorageError(415, "InvalidMimeType", "invalid_mime_type", `mime type ${type} is not supported`);
41
+ const bucketNotEmpty = () => new StorageError(409, "ResourceNotEmpty", "ResourceNotEmpty", "The bucket you tried to delete is not empty");
42
+ const databaseError = (code, message) => {
43
+ if (code === "42501") return accessDenied(message.includes("row-level security") ? void 0 : message);
44
+ if (code === "23505") return alreadyExists();
45
+ if (code === "23503") return new StorageError(404, "InvalidRequest", "InvalidRequest", "The related resource does not exist");
46
+ if (code === "42P01" || code === "3F000") return new StorageError(500, "DatabaseError", "DatabaseError", "This app has no storage schema: restore one with protobase import supabase");
47
+ };
48
+ /**
49
+ * The Storage error an error stands for: its own, a refusal of the database's (RLS, a duplicate key, a missing bucket),
50
+ * or a problem such as a full provider's 507 and the caller's 401. Other errors give `undefined`.
51
+ */
52
+ const storageErrorOf = (error) => {
53
+ if (error instanceof StorageError) return error;
54
+ if (error instanceof HttpProblem) return new StorageError(error.status, error.slug, error.title, error.detail);
55
+ if (!isDatabaseError(error)) return void 0;
56
+ return databaseError(error.code, error.message);
57
+ };
58
+ const errorResponse = (error) => new Response(JSON.stringify(error.body), {
59
+ status: error.status,
60
+ headers: { "content-type": "application/json; charset=utf-8" }
61
+ });
62
+ //#endregion
63
+ export { StorageError, accessDenied, alreadyExists, bucketNotEmpty, bucketNotFound, errorResponse, expiredSignature, invalidBucketName, invalidKey, invalidRequest, invalidSignature, mimeTypeRefused, objectNotFound, storageErrorOf, tooLarge };
@@ -0,0 +1,28 @@
1
+ import { StorageError, errorResponse, storageErrorOf } from "./storage-error.js";
2
+ import { bucketRoutes } from "./bucket-routes.js";
3
+ import { objectRoutes } from "./object-routes.js";
4
+ import { signedRoutes } from "./signed-routes.js";
5
+ import { Hono } from "hono";
6
+ //#region src/storage/storage-routes.ts
7
+ /**
8
+ * The Storage-compatible API `supabase.storage` calls, mounted at `/storage/v1`, over the app's storage provider. Every
9
+ * read and write of `storage.objects` and `storage.buckets` runs as the request's caller (`anon`, `authenticated` or
10
+ * the app's own role), so the policies restored with them decide; public buckets and signed URLs need no key. Errors
11
+ * come back as Supabase Storage's `{ statusCode, error, message }`; an unexpected one as a 500 that names nothing of it.
12
+ */
13
+ const storageRoutes = ({ deps, report }) => {
14
+ const app = new Hono();
15
+ app.onError((error, c) => {
16
+ const known = storageErrorOf(error);
17
+ if (known) return errorResponse(known);
18
+ report?.(error, c.req.raw);
19
+ return errorResponse(new StorageError(500, "InternalError", "Internal", "Internal Server Error"));
20
+ });
21
+ app.route("/", signedRoutes(deps));
22
+ app.route("/", objectRoutes(deps));
23
+ app.route("/", bucketRoutes(deps));
24
+ app.notFound(() => errorResponse(new StorageError(404, "NotFound", "not_found", "Route not found")));
25
+ return app;
26
+ };
27
+ //#endregion
28
+ export { storageRoutes };
@@ -0,0 +1,28 @@
1
+ import { expiredSignature, invalidSignature } from "./storage-error.js";
2
+ //#region src/storage/storage-token.ts
3
+ const encode = (value) => btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(value)))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
4
+ const decode = (text) => {
5
+ if (!/^[\w-]+$/.test(text)) return void 0;
6
+ try {
7
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(text.replaceAll("-", "+").replaceAll("_", "/")), (char) => char.charCodeAt(0))));
8
+ } catch (error) {
9
+ if (error instanceof SyntaxError || error instanceof DOMException && error.name === "InvalidCharacterError") return void 0;
10
+ throw error;
11
+ }
12
+ };
13
+ /** The `token` of a signed URL: the grant and the signature over it, both URL-safe, joined by `.`. */
14
+ const signGrant = async (signer, purpose, grant) => `${encode(grant)}.${await signer.sign(purpose, [grant])}`;
15
+ /**
16
+ * The grant of a token signed for `purpose`, when it is for the object at `bucket` and `name` and has not expired at
17
+ * `now`; else 400 `InvalidSignature` or `ExpiredToken`, as Supabase Storage answers.
18
+ */
19
+ const readGrant = async (signer, purpose, token, object, now = Date.now()) => {
20
+ const [payload = "", signature = ""] = token.split(".");
21
+ const grant = decode(payload);
22
+ if (typeof grant !== "object" || grant === null || !await signer.verify(purpose, [grant], signature)) throw invalidSignature();
23
+ if (grant.bucket !== object.bucket || grant.name !== object.name) throw invalidSignature();
24
+ if (grant.expires * 1e3 < now) throw expiredSignature();
25
+ return grant;
26
+ };
27
+ //#endregion
28
+ export { readGrant, signGrant };
@@ -0,0 +1,37 @@
1
+ import { accepts } from "../files/media-types.js";
2
+ import { findBucket } from "./bucket-sql.js";
3
+ import { bucketNotFound, mimeTypeRefused } from "./storage-error.js";
4
+ import { asApp } from "./storage-db.js";
5
+ import { writeObject } from "./write-object.js";
6
+ import { incomingObject } from "./object-body.js";
7
+ //#region src/storage/upload-object.ts
8
+ /** The bucket an upload goes to, read as the app, as Supabase Storage reads it: its size limit and allowed types. */
9
+ const uploadBucket = async (deps, id) => {
10
+ const bucket = await asApp(deps, (trx) => findBucket(trx, id));
11
+ if (!bucket) throw bucketNotFound();
12
+ return bucket;
13
+ };
14
+ /**
15
+ * Stores the object a request uploads at `target` (see `writeObject`), within the bucket's size limit and the app's,
16
+ * and of a type the bucket allows. Answers what storage-js reads back: the row's id and `<bucket>/<name>`.
17
+ */
18
+ const uploadObject = async (deps, request, target) => {
19
+ const bucket = await uploadBucket(deps, target.bucket);
20
+ const maxBytes = Math.min(bucket.file_size_limit ?? Infinity, deps.maxBytes);
21
+ const incoming = await incomingObject(request, maxBytes);
22
+ const type = incoming.type.split(";", 1)[0].trim().toLowerCase();
23
+ if (bucket.allowed_mime_types?.length && !accepts(bucket.allowed_mime_types, type)) {
24
+ await incoming.body.cancel();
25
+ throw mimeTypeRefused(type);
26
+ }
27
+ const { id } = await writeObject(deps, target, {
28
+ ...incoming,
29
+ store: (key) => deps.store.put(key, incoming.body)
30
+ });
31
+ return {
32
+ Id: id,
33
+ Key: `${target.bucket}/${target.name}`
34
+ };
35
+ };
36
+ //#endregion
37
+ export { uploadBucket, uploadObject };
@@ -0,0 +1,85 @@
1
+ import { accessDenied, alreadyExists } from "./storage-error.js";
2
+ import { objectKey } from "./object-key.js";
3
+ import { findObject, insertObject, updateObject } from "./object-sql.js";
4
+ import { asApp, becomeCaller } from "./storage-db.js";
5
+ //#region src/storage/write-object.ts
6
+ /** The owner Supabase records for a write: the signed-in user, or nobody for the keys alone. */
7
+ const ownerOf = (caller) => caller.role === "authenticated" && typeof caller.claims.sub === "string" ? caller.claims.sub : null;
8
+ const writeRow = async (trx, deps, target, write) => {
9
+ const existing = await findObject(trx, target.bucket, target.name, true);
10
+ if (existing && !target.upsert) throw alreadyExists();
11
+ await becomeCaller(trx, deps, target.caller);
12
+ if (!existing) return {
13
+ id: await insertObject(trx, target.bucket, target.name, write),
14
+ replaced: void 0
15
+ };
16
+ if (!await updateObject(trx, existing.id, write)) throw accessDenied();
17
+ return {
18
+ id: existing.id,
19
+ replaced: existing
20
+ };
21
+ };
22
+ var ProbeDone = class extends Error {};
23
+ /**
24
+ * Whether the caller may write the object, decided by the restored policies on a row written and rolled back, before
25
+ * any byte is stored: a refused upload costs no storage. Throws the refusal.
26
+ */
27
+ const probeWrite = async (deps, target, type) => {
28
+ const metadata = {
29
+ eTag: "",
30
+ size: 0,
31
+ mimetype: type,
32
+ cacheControl: "",
33
+ lastModified: (/* @__PURE__ */ new Date()).toISOString(),
34
+ contentLength: 0,
35
+ httpStatusCode: 200
36
+ };
37
+ await asApp(deps, async (trx) => {
38
+ await writeRow(trx, deps, target, {
39
+ owner: target.owner,
40
+ version: crypto.randomUUID(),
41
+ metadata,
42
+ user_metadata: null
43
+ });
44
+ throw new ProbeDone();
45
+ }).catch((error) => {
46
+ if (!(error instanceof ProbeDone)) throw error;
47
+ });
48
+ };
49
+ /**
50
+ * Writes an object: checks the caller may (`probeWrite`), stores the bytes under a new version, then writes the row
51
+ * as the caller in one transaction, so the policies decide again on the real row. When that fails, the new bytes are
52
+ * deleted; once it commits, the bytes of the object it replaced are.
53
+ */
54
+ const writeObject = async (deps, target, bytes) => {
55
+ await probeWrite(deps, target, bytes.type);
56
+ const version = crypto.randomUUID();
57
+ const key = await objectKey(target.bucket, target.name, version);
58
+ const { size } = await bytes.store(key);
59
+ const metadata = {
60
+ eTag: `"${version}"`,
61
+ size,
62
+ mimetype: bytes.type,
63
+ cacheControl: bytes.cacheControl,
64
+ lastModified: (/* @__PURE__ */ new Date()).toUTCString(),
65
+ contentLength: size,
66
+ httpStatusCode: 200
67
+ };
68
+ const write = {
69
+ owner: target.owner,
70
+ version,
71
+ metadata,
72
+ user_metadata: bytes.userMetadata
73
+ };
74
+ const written = await asApp(deps, (trx) => writeRow(trx, deps, target, write)).catch(async (error) => {
75
+ await deps.store.delete(key);
76
+ throw error;
77
+ });
78
+ if (written.replaced) await deps.store.delete(await objectKey(target.bucket, target.name, written.replaced.version));
79
+ return {
80
+ id: written.id,
81
+ metadata
82
+ };
83
+ };
84
+ //#endregion
85
+ export { ownerOf, probeWrite, writeObject };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
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": {
@@ -32,9 +32,9 @@
32
32
  "dependencies": {
33
33
  "@better-auth/passkey": "^1.7.7",
34
34
  "@better-auth/sso": "^1.7.7",
35
- "@protobase/layout": "0.30.0",
36
- "@protobase/query": "0.30.0",
37
- "@protobase/schema": "0.30.0",
35
+ "@protobase/layout": "0.30.1",
36
+ "@protobase/query": "0.30.1",
37
+ "@protobase/schema": "0.30.1",
38
38
  "@react-email/render": "^2.1.0",
39
39
  "@scalar/hono-api-reference": "^0.12.10",
40
40
  "@sentry/core": "^11.7.0",