1import * as GCP from "alchemy/GCP";
2import * as Effect from "effect/Effect";
3import { HttpServerRequest } from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5import {
6 documentFor,
7 Files,
8 isValidName,
9 objectFor,
10 Uploads,
11} from "./resources.ts";
12
13/**
14 * The front door of an upload pipeline.
15 *
16 * Uploads go straight to Cloud Storage; this service never computes
17 * anything about them. The {@link ../Indexer.ts Indexer} picks each one
18 * up from the bucket's notification and writes the metadata that
19 * `GET /files` serves.
20 *
21 * - `PUT /files/:name` — store the request body at `uploads/:name`.
22 * - `GET /files/:name` — return the stored bytes.
23 * - `GET /files` — list the indexed metadata from Firestore.
24 * - `DELETE /files/:name` — delete the object and its metadata.
25 *
26 * `invokerIamDisabled: true` makes the service public. Put real
27 * authentication in front of it before storing anything that matters.
28 */
29export default class Api extends GCP.Function<Api>()(
30 "Api",
31 {
32 main: import.meta.url,
33 invokerIamDisabled: true,
34 },
35 Effect.gen(function* () {
36 const bucket = yield* Uploads;
37 const database = yield* Files;
38
39 // Least privilege per verb: objectViewer to read, objectUser to write
40 // (overwriting an object needs delete, which objectCreator lacks).
41 const reader = yield* GCP.Storage.ReadBucket(bucket);
42 const writer = yield* GCP.Storage.WriteBucket(bucket);
43 // Listing metadata and removing it on delete.
44 const files = yield* GCP.Firestore.ReadWriteDatabase(database);
45
46 return {
47 fetch: Effect.gen(function* () {
48 const request = yield* HttpServerRequest;
49 const url = new URL(request.originalUrl);
50 const segments = url.pathname.split("/").filter(Boolean);
51
52 if (request.method === "GET" && segments.length === 0) {
53 return HttpServerResponse.text("ok");
54 }
55
56 if (
57 request.method === "GET" &&
58 segments.length === 1 &&
59 segments[0] === "files"
60 ) {
61 const { documents } = yield* files
62 .list("files", { pageSize: 100 })
63 .pipe(Effect.orDie);
64 return yield* HttpServerResponse.json({
65 files: documents.map((document) => document.fields),
66 });
67 }
68
69 if (segments.length !== 2 || segments[0] !== "files") {
70 return yield* HttpServerResponse.json(
71 { error: "not found" },
72 { status: 404 },
73 );
74 }
75
76 const name = decodeURIComponent(segments[1]!);
77 if (!isValidName(name)) {
78 return yield* HttpServerResponse.json(
79 { error: "names are 1-128 characters of [A-Za-z0-9._-]" },
80 { status: 400 },
81 );
82 }
83
84 if (request.method === "PUT") {
85 const body = new Uint8Array(yield* request.arrayBuffer);
86 const object = yield* writer
87 .put(objectFor(name), body, {
88 contentType:
89 request.headers["content-type"] ?? "application/octet-stream",
90 })
91 .pipe(Effect.orDie);
92 // 202: the bytes are stored, the metadata follows asynchronously.
93 return yield* HttpServerResponse.json(
94 {
95 name,
96 object: object.name,
97 generation: object.generation,
98 size: Number(object.size ?? body.byteLength),
99 },
100 { status: 202 },
101 );
102 }
103
104 if (request.method === "GET") {
105 const object = yield* reader.get(objectFor(name)).pipe(Effect.orDie);
106 if (object === undefined) {
107 return yield* HttpServerResponse.json(
108 { error: "no such file" },
109 { status: 404 },
110 );
111 }
112 return HttpServerResponse.uint8Array(object.body, {
113 contentType: object.contentType ?? "application/octet-stream",
114 });
115 }
116
117 if (request.method === "DELETE") {
118 yield* writer.delete(objectFor(name)).pipe(Effect.orDie);
119 yield* files.delete(documentFor(name)).pipe(Effect.orDie);
120 return HttpServerResponse.empty({ status: 204 });
121 }
122
123 return yield* HttpServerResponse.json(
124 { error: "method not allowed" },
125 { status: 405 },
126 );
127 }),
128 };
129 }).pipe(
130 Effect.provide([
131 GCP.Storage.ReadBucketHttp,
132 GCP.Storage.WriteBucketHttp,
133 GCP.Firestore.ReadWriteDatabaseHttp,
134 ]),
135 ),
136) {}