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 { ApiKey, Links } from "./resources.ts";
6
7/** Base62 so codes stay short and URL-safe. */
8const ALPHABET =
9 "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
10
11const newCode = () =>
12 Array.from(
13 crypto.getRandomValues(new Uint8Array(7)),
14 (byte) => ALPHABET[byte % ALPHABET.length],
15 ).join("");
16
17/**
18 * Cloud Run terminates TLS at its front end and forwards plain HTTP, so
19 * the public origin comes from the forwarded headers.
20 */
21const publicOrigin = (request: HttpServerRequest) =>
22 `${request.headers["x-forwarded-proto"] ?? "https"}://${request.headers.host}`;
23
24/** Constant-time comparison so response timing does not leak the key. */
25const sameKey = (expected: string, given: string | undefined) => {
26 if (given === undefined || given.length !== expected.length) return false;
27 let diff = 0;
28 for (let index = 0; index < expected.length; index++) {
29 diff |= expected.charCodeAt(index) ^ given.charCodeAt(index);
30 }
31 return diff === 0;
32};
33
34interface Link {
35 url: string;
36 clicks: number;
37 createdAt: string;
38}
39
40/**
41 * A link shortener on Cloud Run.
42 *
43 * This is the shape of most GCP web services: one Cloud Run container,
44 * Firestore for state, Secret Manager for the credential the container
45 * needs at runtime. Nothing is wired by hand — each `yield*` of a binding
46 * grants the matching IAM role on the service's runtime service account
47 * and injects whatever the call needs into the revision.
48 *
49 * - `POST /links` — mint a code for a URL (requires `x-api-key`).
50 * - `GET /l/:code` — redirect and count the click.
51 * - `GET /links/:code` — read the link back.
52 * - `DELETE /links/:code` — retire a code (requires `x-api-key`).
53 *
54 * `invokerIamDisabled: true` makes the service publicly reachable, which
55 * a link shortener has to be. Drop it and Cloud Run requires a signed
56 * Google identity token on every request.
57 */
58export default class Api extends GCP.Function<Api>()(
59 "Api",
60 {
61 main: import.meta.url,
62 invokerIamDisabled: true,
63 },
64 Effect.gen(function* () {
65 const links = yield* Links;
66 const apiKey = yield* ApiKey;
67
68 // Each binding grants the runtime service account one role:
69 // datastore.user on the project, under an IAM Condition naming only
70 // this database, and secretmanager.secretAccessor on the API key
71 // secret only.
72 const db = yield* GCP.Firestore.ReadWriteDatabase(links);
73 const key = yield* GCP.SecretManager.ReadSecret(apiKey);
74
75 const readLink = (code: string) =>
76 db.get(`links/${code}`).pipe(
77 Effect.map((document): Link | undefined => {
78 // `get` answers `undefined` for a missing document, and fields
79 // come back as plain JavaScript values.
80 const { url, clicks, createdAt } = document?.fields ?? {};
81 if (typeof url !== "string") return undefined;
82 return {
83 url,
84 clicks: typeof clicks === "number" ? clicks : 0,
85 createdAt: createdAt instanceof Date ? createdAt.toISOString() : "",
86 };
87 }),
88 Effect.orDie,
89 );
90
91 /**
92 * Secret Manager holds the key; the container reads the `latest`
93 * version on demand. Until someone adds a version the API cannot
94 * authenticate anyone, so say so instead of failing open.
95 */
96 const authorize = (request: HttpServerRequest) =>
97 key.access().pipe(
98 Effect.map((expected) => {
99 if (expected === undefined) return "unconfigured" as const;
100 return sameKey(expected, request.headers["x-api-key"])
101 ? ("ok" as const)
102 : ("denied" as const);
103 }),
104 Effect.orDie,
105 );
106
107 return {
108 fetch: Effect.gen(function* () {
109 const request = yield* HttpServerRequest;
110 const url = new URL(request.originalUrl);
111 const segments = url.pathname.split("/").filter(Boolean);
112
113 if (request.method === "GET" && segments.length === 0) {
114 return HttpServerResponse.text("ok");
115 }
116
117 // Mint a code. 62^7 keeps collisions rare, and `create` makes
118 // them harmless.
119 if (request.method === "POST" && url.pathname === "/links") {
120 const auth = yield* authorize(request);
121 if (auth !== "ok") {
122 return yield* HttpServerResponse.json(
123 {
124 error:
125 auth === "unconfigured"
126 ? "no api key version has been added to the secret"
127 : "invalid api key",
128 },
129 { status: auth === "unconfigured" ? 503 : 401 },
130 );
131 }
132
133 const body = (yield* request.json) as { url?: string };
134 if (!body.url) {
135 return yield* HttpServerResponse.json(
136 { error: "url is required" },
137 { status: 400 },
138 );
139 }
140
141 // `create` fails with DocumentAlreadyExists instead of
142 // overwriting, so a collision just mints another code.
143 const code = yield* Effect.suspend(() => {
144 const code = newCode();
145 return db
146 .create(`links/${code}`, {
147 url: body.url,
148 clicks: 0,
149 createdAt: new Date(),
150 })
151 .pipe(Effect.as(code));
152 }).pipe(
153 Effect.retry({
154 while: (error) =>
155 error._tag === "GCP.Firestore.DocumentAlreadyExists",
156 times: 3,
157 }),
158 Effect.orDie,
159 );
160
161 return yield* HttpServerResponse.json(
162 { code, shortUrl: `${publicOrigin(request)}/l/${code}` },
163 { status: 201 },
164 );
165 }
166
167 // Follow a link. The click counter is a read-modify-write, which
168 // is fine for a counter nobody bills on; a Firestore transaction
169 // is the answer when the count has to be exact.
170 if (request.method === "GET" && segments[0] === "l" && segments[1]) {
171 const link = yield* readLink(segments[1]);
172 if (link === undefined) {
173 return yield* HttpServerResponse.json(
174 { error: "unknown code" },
175 { status: 404 },
176 );
177 }
178
179 // `update` writes only the keys it is given.
180 yield* db
181 .update(`links/${segments[1]}`, { clicks: link.clicks + 1 })
182 .pipe(Effect.orDie);
183
184 return HttpServerResponse.empty({
185 status: 302,
186 headers: { location: link.url },
187 });
188 }
189
190 if (
191 request.method === "GET" &&
192 segments[0] === "links" &&
193 segments[1]
194 ) {
195 const link = yield* readLink(segments[1]);
196 if (link === undefined) {
197 return yield* HttpServerResponse.json(
198 { error: "unknown code" },
199 { status: 404 },
200 );
201 }
202 return yield* HttpServerResponse.json({ code: segments[1], ...link });
203 }
204
205 if (
206 request.method === "DELETE" &&
207 segments[0] === "links" &&
208 segments[1]
209 ) {
210 const auth = yield* authorize(request);
211 if (auth !== "ok") {
212 return yield* HttpServerResponse.json(
213 { error: "invalid api key" },
214 { status: 401 },
215 );
216 }
217 // Deleting a missing document succeeds.
218 yield* db.delete(`links/${segments[1]}`).pipe(Effect.orDie);
219 return HttpServerResponse.empty({ status: 204 });
220 }
221
222 return yield* HttpServerResponse.json(
223 { error: "not found" },
224 { status: 404 },
225 );
226 }),
227 };
228 }).pipe(
229 Effect.provide([
230 GCP.Firestore.ReadWriteDatabaseHttp,
231 GCP.SecretManager.ReadSecretHttp,
232 ]),
233 ),
234) {}