@protobase/server 0.30.0 → 0.30.2

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.
@@ -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,9 @@
1
+ //#region src/storage/object-key.d.ts
2
+ /**
3
+ * Where an object's bytes are kept in the storage provider: `storage/<sha-256 of bucket and name>/<version>`. Names may
4
+ * hold any character, which no provider takes in a path; the hash takes every one, and the version keeps an upload apart
5
+ * from the one it replaces until the database says which is current. A row without a version, as an import may leave
6
+ * it, is at `unversioned`.
7
+ */
8
+ export declare const objectKey: (bucket: string, name: string, version: string | null) => Promise<string>;
9
+ //#endregion
@@ -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 };