Compare commits
10
Commits
cf3afb403c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25dd577681 | ||
|
|
aeb659f3de | ||
|
|
eab717b7b2 | ||
|
|
33bcee7104 | ||
|
|
7296907963 | ||
|
|
3773a86a54 | ||
|
|
d6244ba404 | ||
|
|
f4a8b0174b | ||
|
|
3e85f0c91e | ||
|
|
2dfe001439 |
+34
-17
@@ -78,18 +78,19 @@ function readPresigned(
|
|||||||
): { uploadUrl: string; attachmentId: string; s3Key: string } | null {
|
): { uploadUrl: string; attachmentId: string; s3Key: string } | null {
|
||||||
const root = rec(body);
|
const root = rec(body);
|
||||||
const data = root.data;
|
const data = root.data;
|
||||||
// The entry may be the first element of an array, the first element of a
|
// The confirmed shape is { data: { uploads: [{ attachmentId, presignedUrl,
|
||||||
// named list, or the data object itself.
|
// s3Key, index }] } }. The entry may also arrive as a bare array, under a
|
||||||
|
// differently named list, or as the data object itself, so try each.
|
||||||
const candidates: unknown[] = [];
|
const candidates: unknown[] = [];
|
||||||
if (Array.isArray(data)) candidates.push(...data);
|
if (Array.isArray(data)) candidates.push(...data);
|
||||||
else if (Array.isArray(body)) candidates.push(...(body as unknown[]));
|
else if (Array.isArray(body)) candidates.push(...(body as unknown[]));
|
||||||
else {
|
// Named lists can live under `data` or at the root.
|
||||||
const d = rec(data);
|
for (const container of [rec(data), root]) {
|
||||||
for (const key of ["files", "urls", "attachments", "presignedUrls"]) {
|
for (const key of ["uploads", "files", "urls", "attachments", "presignedUrls"]) {
|
||||||
if (Array.isArray(d[key])) candidates.push(...(d[key] as unknown[]));
|
if (Array.isArray(container[key])) candidates.push(...(container[key] as unknown[]));
|
||||||
}
|
}
|
||||||
candidates.push(d);
|
|
||||||
}
|
}
|
||||||
|
candidates.push(rec(data));
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
const e = rec(c);
|
const e = rec(c);
|
||||||
const uploadUrl =
|
const uploadUrl =
|
||||||
@@ -189,7 +190,21 @@ export async function runMediaPipeline(
|
|||||||
options: RefreshOptions = {},
|
options: RefreshOptions = {},
|
||||||
): Promise<CreateMediaPostResult> {
|
): Promise<CreateMediaPostResult> {
|
||||||
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
|
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
|
||||||
const failed = { ok: false, status: 502, error: "post_failed" } as const;
|
const failedResult = { ok: false, status: 502, error: "post_failed" } as const;
|
||||||
|
// Log which step failed with its status and Minnect's message, never a token,
|
||||||
|
// presigned URL or file bytes, so a failed publish can be diagnosed.
|
||||||
|
const fail = async (step: string, res?: Response, note?: string) => {
|
||||||
|
let message = note ?? "";
|
||||||
|
if (res) {
|
||||||
|
const body = rec(await res.clone().json().catch(() => null));
|
||||||
|
const m = body.message ?? body.error ?? rec(body.error).message;
|
||||||
|
message = [`status ${res.status}`, typeof m === "string" ? m : "", note ?? ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
console.warn(`[post-media] ${mediaType} ${step} failed: ${message}`);
|
||||||
|
return failedResult;
|
||||||
|
};
|
||||||
const doFetch = options.fetchImpl ?? fetch;
|
const doFetch = options.fetchImpl ?? fetch;
|
||||||
const { file, mediaType } = upload;
|
const { file, mediaType } = upload;
|
||||||
|
|
||||||
@@ -222,9 +237,9 @@ export async function runMediaPipeline(
|
|||||||
visibility: "public",
|
visibility: "public",
|
||||||
});
|
});
|
||||||
if (created === "sign_in") return signIn;
|
if (created === "sign_in") return signIn;
|
||||||
if (created.status !== 200 && created.status !== 201) return failed;
|
if (created.status !== 200 && created.status !== 201) return fail("draft", created);
|
||||||
const info = readCreated(await created.json().catch(() => null));
|
const info = readCreated(await created.json().catch(() => null));
|
||||||
if (!info) return failed;
|
if (!info) return fail("draft", undefined, "no post id in response");
|
||||||
|
|
||||||
// 2. Presigned URL for the one file.
|
// 2. Presigned URL for the one file.
|
||||||
const presignedRes = await call("/api/v1/attachments/presigned-urls", {
|
const presignedRes = await call("/api/v1/attachments/presigned-urls", {
|
||||||
@@ -232,9 +247,9 @@ export async function runMediaPipeline(
|
|||||||
files: [{ name: file.name, type: file.type, size: file.size }],
|
files: [{ name: file.name, type: file.type, size: file.size }],
|
||||||
});
|
});
|
||||||
if (presignedRes === "sign_in") return signIn;
|
if (presignedRes === "sign_in") return signIn;
|
||||||
if (presignedRes.status < 200 || presignedRes.status > 299) return failed;
|
if (presignedRes.status < 200 || presignedRes.status > 299) return fail("presign", presignedRes);
|
||||||
const slot = readPresigned(await presignedRes.json().catch(() => null));
|
const slot = readPresigned(await presignedRes.json().catch(() => null));
|
||||||
if (!slot) return failed;
|
if (!slot) return fail("presign", undefined, "no upload slot in response");
|
||||||
|
|
||||||
// 3. The server PUTs the bytes. No Authorization header: the presigned URL
|
// 3. The server PUTs the bytes. No Authorization header: the presigned URL
|
||||||
// is pre-authorized, and the feed token must never reach S3 or the browser.
|
// is pre-authorized, and the feed token must never reach S3 or the browser.
|
||||||
@@ -244,7 +259,7 @@ export async function runMediaPipeline(
|
|||||||
headers: { "Content-Type": file.type },
|
headers: { "Content-Type": file.type },
|
||||||
body: bytes,
|
body: bytes,
|
||||||
});
|
});
|
||||||
if (!put.ok) return failed;
|
if (!put.ok) return fail("upload", undefined, `status ${put.status}`);
|
||||||
|
|
||||||
// 4. Confirm (image confirmation blocks until processing finishes).
|
// 4. Confirm (image confirmation blocks until processing finishes).
|
||||||
const confirmed = await call("/api/v1/attachments/confirm-uploads", {
|
const confirmed = await call("/api/v1/attachments/confirm-uploads", {
|
||||||
@@ -259,17 +274,19 @@ export async function runMediaPipeline(
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (confirmed === "sign_in") return signIn;
|
if (confirmed === "sign_in") return signIn;
|
||||||
if (confirmed.status < 200 || confirmed.status > 299) return failed;
|
if (confirmed.status < 200 || confirmed.status > 299) return fail("confirm", confirmed);
|
||||||
|
|
||||||
// 5. Publish, same call as a text post.
|
// 5. Publish, same call as a text post.
|
||||||
const published = await call(`/api/v1/posts/${encodeURIComponent(info.id)}/publish`, {
|
const published = await call(`/api/v1/posts/${encodeURIComponent(info.id)}/publish`, {
|
||||||
content: caption,
|
content: caption,
|
||||||
});
|
});
|
||||||
if (published === "sign_in") return signIn;
|
if (published === "sign_in") return signIn;
|
||||||
if (published.status < 200 || published.status > 299) return failed;
|
if (published.status < 200 || published.status > 299) return fail("publish", published);
|
||||||
|
|
||||||
return { ok: true, id: info.id, status: info.status };
|
return { ok: true, id: info.id, status: info.status };
|
||||||
} catch {
|
} catch (e) {
|
||||||
return failed;
|
const code = e instanceof Error ? (e.cause as { code?: unknown } | undefined)?.code : undefined;
|
||||||
|
const note = e instanceof Error ? `${e.name}: ${e.message} ${code ?? ""}`.trim() : "error";
|
||||||
|
return fail("pipeline", undefined, note);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,32 @@ describe("POST /api/posts (image)", () => {
|
|||||||
expect(calls.filter((c) => c.url.includes("/api/v1/")).every((c) => c.auth === "Bearer T")).toBe(true);
|
expect(calls.filter((c) => c.url.includes("/api/v1/")).every((c) => c.auth === "Bearer T")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads the real { data: { uploads: [{ presignedUrl, ... }] } } shape", async () => {
|
||||||
|
const sid = session();
|
||||||
|
const calls: { url: string; method: string }[] = [];
|
||||||
|
const fn = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||||
|
const u = String(url);
|
||||||
|
calls.push({ url: u, method: String(init?.method ?? "GET") });
|
||||||
|
if (u.endsWith("/api/v1/posts")) return jsonRes({ data: { id: "p9", status: "draft" } }, 201);
|
||||||
|
if (u.endsWith("/attachments/presigned-urls"))
|
||||||
|
return jsonRes(
|
||||||
|
{ data: { uploads: [{ attachmentId: "a1", presignedUrl: "https://s3.example/put?sig=9", s3Key: "k/1.jpg", index: 0 }] } },
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
if (u === "https://s3.example/put?sig=9") return new Response(null, { status: 200 });
|
||||||
|
if (u.endsWith("/attachments/confirm-uploads")) return jsonRes({ data: { success: true } }, 200);
|
||||||
|
if (u.includes("/publish")) return jsonRes({ data: { success: true } }, 200);
|
||||||
|
return new Response("{}", { status: 404 });
|
||||||
|
});
|
||||||
|
const res = await withFetch(fn as unknown as typeof fetch, () =>
|
||||||
|
POST(multipart("look", imageFile(), sid)),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ id: "p9" });
|
||||||
|
const put = calls.find((c) => c.method === "PUT");
|
||||||
|
expect(put?.url).toBe("https://s3.example/put?sig=9");
|
||||||
|
});
|
||||||
|
|
||||||
it("never returns the feed token to the browser", async () => {
|
it("never returns the feed token to the browser", async () => {
|
||||||
const sid = session();
|
const sid = session();
|
||||||
const fn = vi.fn(async (url: string | URL) => {
|
const fn = vi.fn(async (url: string | URL) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user