Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions capture/public/service-worker.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const CACHE_NAME = "notes-capture-v2";

const PUBLIC_ASSETS = ["/manifest.webmanifest", "/icons/icon.svg"];

self.addEventListener("install", (event) => {
Expand Down
2 changes: 2 additions & 0 deletions capture/src/capture/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const GENERIC_CAPTURE_ERROR =
const CaptureErrorResponse = Schema.Struct({
error: Schema.Literals(Object.values(CAPTURE_ERRORS)),
});

const decodeCaptureErrorOption =
Schema.decodeUnknownOption(CaptureErrorResponse);

Expand All @@ -25,5 +26,6 @@ export const decodeCaptureError = <Input>(value: Input) =>

export function captureErrorMessage<Input>(value: Input): string {
const error = decodeCaptureError(value);

return error ? `${error}. Your text is still here.` : GENERIC_CAPTURE_ERROR;
}
3 changes: 3 additions & 0 deletions capture/src/capture/issuePayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ export interface IssuePayload {

function defaultTitle(text: string): string {
const firstLine = text.split("\n", 1)[0]?.trim() ?? "";

if (firstLine.length <= 72) return firstLine;

return `${firstLine.slice(0, 69).trimEnd()}...`;
}

Expand All @@ -20,6 +22,7 @@ export function buildIssuePayload(
queueLabel: string,
): IssuePayload {
const title = capture.titleHint?.trim() || defaultTitle(capture.text);

return {
title,
labels: [queueLabel],
Expand Down
7 changes: 7 additions & 0 deletions capture/src/capture/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ export function parseRepositoryOptions(
if (!raw) return undefined;

const options = Schema.decodeUnknownSync(RepositoryOptions)(JSON.parse(raw));

if (options.length === 0) return undefined;

const repositories = new Set(options.map((option) => option.repository));

if (repositories.size !== options.length) {
throw new Error("Capture repositories contain duplicates");
}

return options;
}

Expand All @@ -31,16 +34,20 @@ export function validateTargetRepository(
options: readonly RepositoryOption[] | undefined,
): void {
if (selectedRepository === undefined) return;

if (options?.some(({ repository }) => repository === selectedRepository)) {
return;
}

throw new Error("Capture repository is not allowed");
}

export function splitRepository(repository: string): readonly [string, string] {
const separator = repository.indexOf("/");

if (separator <= 0 || separator === repository.length - 1) {
throw new Error("Capture repository is invalid");
}

return [repository.slice(0, separator), repository.slice(separator + 1)];
}
8 changes: 6 additions & 2 deletions capture/src/capture/services/AccessAuth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Schema } from "effect";
import { Option, Schema } from "effect";
import { createRemoteJWKSet, jwtVerify } from "jose";

export interface AccessIdentity {
Expand All @@ -16,20 +16,24 @@ export async function verifyAccessRequest(
config: AccessConfig,
): Promise<AccessIdentity> {
const token = request.headers.get("Cf-Access-Jwt-Assertion");

if (!token || config.audience === "configure-after-access-app-creation") {
throw new Error("Cloudflare Access authentication is not configured");
}

const issuer = `https://${config.teamDomain}`;
const keys = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`));

const { payload } = await jwtVerify(token, keys, {
audience: config.audience,
issuer,
});

if (!payload.sub) throw new Error("Cloudflare Access token has no subject");

const email = Schema.decodeUnknownOption(Schema.String)(payload.email);
return email._tag === "Some"

return Option.isSome(email)
? { subject: payload.sub, email: email.value }
: { subject: payload.sub };
}
3 changes: 3 additions & 0 deletions capture/src/capture/services/GitHubIssues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,20 @@ export async function createGitHubIssue(
body: JSON.stringify(payload),
},
);

if (!response.ok) {
throw new Error(`GitHub issue creation failed (${response.status})`);
}

let result: typeof GitHubIssueResponse.Type;

try {
result = Schema.decodeUnknownSync(GitHubIssueResponse)(
await response.json(),
);
} catch {
throw new Error("GitHub returned an invalid issue response");
}

return { number: result.number, url: result.html_url };
}
1 change: 1 addition & 0 deletions capture/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const onRequest = defineMiddleware(async ({ request }, next) => {
audience: env.ACCESS_AUD,
teamDomain: env.ACCESS_TEAM_DOMAIN,
});

return next();
} catch {
return new Response("Unauthorized", { status: 401 });
Expand Down
14 changes: 14 additions & 0 deletions capture/src/pages/api/captures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,42 +32,51 @@ export const POST = (async ({ request }) => {
reason: "content-type",
status: 415,
});

return json({ error: CAPTURE_ERRORS.expectedJson }, 415);
}

const length = Number(request.headers.get("Content-Length") ?? 0);

if (length > MAX_REQUEST_BYTES) {
console.warn("Capture submission rejected", {
reason: "declared-size",
status: 413,
bytes: length,
});

return json({ error: CAPTURE_ERRORS.tooLarge }, 413);
}

const raw = await request.text();
const bytes = new TextEncoder().encode(raw).byteLength;

if (bytes > MAX_REQUEST_BYTES) {
console.warn("Capture submission rejected", {
reason: "measured-size",
status: 413,
bytes,
});

return json({ error: CAPTURE_ERRORS.tooLarge }, 413);
}

let capture: Capture;

try {
capture = decodeCapture(JSON.parse(raw));
} catch {
console.warn("Capture submission rejected", {
reason: "invalid-capture",
status: 400,
});

return json({ error: CAPTURE_ERRORS.invalidCapture }, 400);
}

const defaultRepository = `${env.GITHUB_OWNER}/${env.GITHUB_REPO}`;
let repositories: readonly RepositoryOption[] | undefined;

try {
repositories = parseRepositoryOptions(env.CAPTURE_REPOSITORIES);
} catch {
Expand All @@ -76,11 +85,13 @@ export const POST = (async ({ request }) => {
status: 500,
requestId: capture.requestId,
});

return json({ error: CAPTURE_ERRORS.invalidConfiguration }, 500);
}

let owner: string;
let repository: string;

try {
validateTargetRepository(capture.repository, repositories);
[owner, repository] = splitRepository(defaultRepository);
Expand All @@ -90,6 +101,7 @@ export const POST = (async ({ request }) => {
status: 400,
requestId: capture.requestId,
});

return json({ error: CAPTURE_ERRORS.invalidRepository }, 400);
}

Expand All @@ -105,13 +117,15 @@ export const POST = (async ({ request }) => {
}),
}),
);

return json(issue, 201);
} catch {
console.error("Capture submission failed", {
reason: "queue",
status: 502,
requestId: capture.requestId,
});

return json({ error: CAPTURE_ERRORS.queueFailed }, 502);
}
}) satisfies APIRoute;
Loading
Loading