Author SHA1 Message Date
sneak e185299860 Thumbnail upload PUT sends the signed Content-MD5 (closes #238)
check / check (push) Successful in 3m19s
`/files/upload-url` signs the content MD5 into the presigned URL, so
storage refused the PUT that `putFile` sent without it. `putFile` now
takes the MD5 given to `getUploadURL` and sends it. The thumbnail test's
fake storage refuses a PUT without the signed header.

Model: opus-5-5
2026-11-11 20:35:03 +00:00
7 changed files with 43 additions and 28 deletions

No files matched your search

+1 -13
View File
@@ -28,20 +28,8 @@ build:
# Bundles the built dist/, so the binary reports the version script/build
# stamped.
#
# On Linux, nix's bun loads through a C library in the nix store, and a binary
# bun compiles from itself keeps that loader. nix-collect-garbage can delete
# that C library, and the binary then no longer starts.
# A --target other than the host's makes bun use its downloaded release build,
# which loads through the system's C library. That is tested only on x86-64,
# with the baseline build, so other Linux machines are refused.
build-bin: build
case "$$(uname -s)-$$(uname -m)" in \
Linux-x86_64) target=--target=bun-linux-x64-baseline ;; \
Linux-*) echo "make build-bin: on Linux only x86-64 is supported" >&2; exit 1 ;; \
*) target= ;; \
esac; \
nix-shell -p bun --run "bun build dist/bin/quak.js --compile $$target --outfile bin/quak"
nix-shell -p bun --run "bun build dist/bin/quak.js --compile --outfile bin/quak"
install: build-bin
mkdir -p ~/bin
+2 -6
View File
@@ -138,10 +138,7 @@ requests. The "Library reference" below covers both.
one binary and copies it to `~/bin/quak`. Besides what Getting Started needs,
`make install` needs `make` and [nix](https://nixos.org/download). It uses bun,
a JavaScript runtime, to bundle the built `dist/` into one executable. It runs
bun through `nix-shell -p bun`. On Linux it works on x86-64 only. There, the
binary is built from bun's own release build rather than nix's bun, so it does
not need the nix store. The first build downloads that release build into
`~/.bun`.
bun through `nix-shell -p bun`.
Log in once with `~/bin/quak`, as the user the cron job will run as:
@@ -1913,8 +1910,7 @@ These apply to people and to coding agents alike.
`bin/clip-keyword-vectors` installs, by SHA-256 hash;
- Node and yarn, when `script/bootstrap` installs them, by version.
- Not pinned:
- bun, which `make build-bin` takes from `nix-shell -p bun`, and on Linux
the release build of the same bun version that it downloads;
- bun, which `make build-bin` takes from `nix-shell -p bun`;
- `make` and `git`, which `script/bootstrap` installs from the system
package manager when they are missing, as every `Dockerfile` stage does;
- `bash` and `curl`, which `script/bootstrap` installs the same way when it
+8 -1
View File
@@ -354,7 +354,13 @@ export class ApiClient {
});
}
async putFile(presignedURL: string, data: Uint8Array): Promise<void> {
// `contentMD5` is the value given to `getUploadURL`. The server signs it
// into the URL, and storage refuses a PUT without that `Content-MD5`.
async putFile(
presignedURL: string,
data: Uint8Array,
contentMD5: string,
): Promise<void> {
// Idempotent despite being a write: a presigned PUT stores one whole
// object at one key in one request, so replaying it either overwrites
// the same bytes or lands them for the first time. There is no partial
@@ -370,6 +376,7 @@ export class ApiClient {
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(data.length),
"Content-MD5": contentMD5,
},
body: data,
signal: AbortSignal.timeout(this.requestTimeoutMs),
+1 -1
View File
@@ -325,7 +325,7 @@ export const fixMissingThumbnails = async (
ciphertext.length,
md5,
);
await api.putFile(url, ciphertext);
await api.putFile(url, ciphertext, md5);
await api.updateThumbnail(file.id, objectKey, toBase64(header));
log(`[${collectionName}] Thumbnail uploaded for ${title}`);
+10 -4
View File
@@ -780,7 +780,7 @@ describe("ApiClient timeouts", () => {
await client.getJSON("/a");
await client.postJSON("/b", {});
await client.putFile("https://s3.example/x", new Uint8Array([1]));
await client.putFile("https://s3.example/x", new Uint8Array([1]), "m");
await client.putJSON("/c", {});
for (const call of calls) {
@@ -825,7 +825,8 @@ describe("ApiClient timeouts", () => {
[
"putFile",
() => new Response(null, { status: 200 }),
(c) => c.putFile("https://s3.example/x", new Uint8Array([1])),
(c) =>
c.putFile("https://s3.example/x", new Uint8Array([1]), "m"),
],
[
"getFileStream",
@@ -1049,7 +1050,7 @@ describe("ApiClient error typing", () => {
const client = new ApiClient({ fetch, retry: noWait });
const err: unknown = await client
.putFile("https://s3.example/obj", new Uint8Array([1, 2]))
.putFile("https://s3.example/obj", new Uint8Array([1, 2]), "m")
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(ApiError);
@@ -1067,7 +1068,11 @@ describe("ApiClient error typing", () => {
);
const client = new ApiClient({ fetch, retry: noWait });
await client.putFile("https://s3.example/obj", new Uint8Array([1, 2]));
await client.putFile(
"https://s3.example/obj",
new Uint8Array([1, 2]),
"m",
);
expect(calls).toHaveLength(2);
});
@@ -1277,6 +1282,7 @@ describe("ApiClient onRequest", () => {
await client.putFile(
"https://s3.example/obj?X-Amz-Signature=abc",
new Uint8Array([1, 2]),
"m",
);
expect(requests).toEqual([
+13 -2
View File
@@ -80,13 +80,20 @@ describe("ApiClient.putFile", () => {
const client = new ApiClient({ fetch, authToken: "secret-tok" });
const data = new Uint8Array([1, 2, 3, 4, 5]);
await client.putFile("https://s3.example.com/presigned?sig=abc", data);
await client.putFile(
"https://s3.example.com/presigned?sig=abc",
data,
"md5base64==",
);
expect(calls[0]!.url).toBe("https://s3.example.com/presigned?sig=abc");
expect(calls[0]!.init?.method).toBe("PUT");
const headers = new Headers(calls[0]!.init?.headers as HeadersInit);
expect(headers.get("Content-Type")).toBe("application/octet-stream");
expect(headers.get("Content-Length")).toBe("5");
// The server signs the MD5 into the URL; storage refuses the PUT
// without it.
expect(headers.get("Content-MD5")).toBe("md5base64==");
// Must NOT leak auth headers to S3
expect(headers.has("X-Auth-Token")).toBe(false);
expect(headers.has("X-Client-Package")).toBe(false);
@@ -99,7 +106,11 @@ describe("ApiClient.putFile", () => {
const client = new ApiClient({ fetch });
await expect(
client.putFile("https://s3.example.com/bad", new Uint8Array(10)),
client.putFile(
"https://s3.example.com/bad",
new Uint8Array(10),
"md5base64==",
),
).rejects.toThrow(/403/);
});
});
+8 -1
View File
@@ -273,6 +273,7 @@ const buildThumbMock = async (opts?: {
const buildThumbFetch = (m: ThumbMockState) => {
let srpServer: SrpServer;
let signedMD5: string | undefined;
return (async (
input: RequestInfo | URL,
@@ -365,8 +366,9 @@ const buildThumbFetch = (m: ThumbMockState) => {
return new Response("not found", { status: 404 });
}
// Upload URL minting
// Upload URL minting. The real server signs the MD5 into the URL.
if (path === "/files/upload-url") {
signedMD5 = JSON.parse(init?.body as string).contentMD5;
return json({
objectKey: `42/thumb-${Date.now()}`,
url: "https://s3.mock.test/presigned-put",
@@ -375,6 +377,11 @@ const buildThumbFetch = (m: ThumbMockState) => {
// Presigned PUT (S3 upload)
if (url.startsWith("https://s3.mock.test/")) {
// Storage refuses a PUT that lacks the signed `Content-MD5`.
const headers = new Headers(init?.headers);
if (headers.get("Content-MD5") !== signedMD5) {
return new Response("AccessDenied", { status: 400 });
}
const body = init?.body;
// Store the uploaded bytes for later inspection
if (body instanceof Uint8Array) {