158 lines
5.2 KiB
TypeScript
158 lines
5.2 KiB
TypeScript
import {
|
|
createUser,
|
|
db,
|
|
del,
|
|
get,
|
|
hasFfmpeg,
|
|
insertDump,
|
|
makeImage,
|
|
PNG,
|
|
test,
|
|
TEST_DIR,
|
|
webpWidth,
|
|
} from "../helpers.ts";
|
|
import { assertEquals } from "@std/assert";
|
|
|
|
function createdAtOf(id: string): string {
|
|
return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as {
|
|
created_at: string;
|
|
}).created_at;
|
|
}
|
|
|
|
function readDir(path: string): string[] {
|
|
try {
|
|
return [...Deno.readDirSync(path)].map((e) => e.name);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
test("GET /api/dumps/:id/thumbnail: serves a cached video still, with privacy", async () => {
|
|
const owner = await createUser();
|
|
const video = insertDump({
|
|
userId: owner.id,
|
|
fileName: "v.mp4",
|
|
fileMime: "video/mp4",
|
|
});
|
|
const priv = insertDump({
|
|
userId: owner.id,
|
|
fileName: "p.mp4",
|
|
fileMime: "video/mp4",
|
|
isPrivate: true,
|
|
});
|
|
Deno.mkdirSync(`${TEST_DIR}/uploads/thumbnails`, { recursive: true });
|
|
for (const { id } of [video, priv]) {
|
|
Deno.writeFileSync(`${TEST_DIR}/uploads/thumbnails/${id}.jpg`, PNG);
|
|
}
|
|
const res = await get(`/api/dumps/${video.id}/thumbnail`);
|
|
assertEquals(res.status, 200);
|
|
assertEquals(res.headers.get("content-type"), "image/jpeg");
|
|
assertEquals(res.headers.get("cache-control"), "public, no-cache");
|
|
const v = (id: string) => `5-${Date.parse(createdAtOf(id))}`;
|
|
assertEquals(
|
|
(await get(`/api/dumps/${video.id}/thumbnail?v=${v(video.id)}`)).headers
|
|
.get(
|
|
"cache-control",
|
|
),
|
|
"public, max-age=31536000, immutable",
|
|
);
|
|
assertEquals((await get(`/api/dumps/${priv.id}/thumbnail`)).status, 404);
|
|
const own = await get(
|
|
`/api/dumps/${priv.id}/thumbnail?v=${v(priv.id)}&token=${owner.mediaToken}`,
|
|
);
|
|
assertEquals(own.status, 200);
|
|
assertEquals(
|
|
own.headers.get("cache-control"),
|
|
"private, max-age=31536000, immutable",
|
|
);
|
|
|
|
const text = insertDump({ userId: owner.id, fileName: "t.txt" });
|
|
assertEquals((await get(`/api/dumps/${text.id}/thumbnail`)).status, 404);
|
|
assertEquals((await get(`/api/dumps/nope/thumbnail`)).status, 400);
|
|
});
|
|
|
|
test("GET /api/dumps/:id/thumbnail: an image dump gets a scaled copy, or itself when none can be made", async () => {
|
|
const owner = await createUser();
|
|
const scaled = (id: string) =>
|
|
readDir(`${TEST_DIR}/uploads/scaled/dumps`).filter((n) => n.startsWith(id))
|
|
.sort();
|
|
|
|
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
|
|
const gif = new TextEncoder().encode("GIF89a-not-really");
|
|
const broken = insertDump({
|
|
userId: owner.id,
|
|
fileName: "a.gif",
|
|
fileMime: "image/gif",
|
|
fileData: gif,
|
|
});
|
|
const fallback = await get(`/api/dumps/${broken.id}/thumbnail`);
|
|
assertEquals(fallback.status, 200);
|
|
assertEquals(fallback.headers.get("content-type"), "image/gif");
|
|
assertEquals(fallback.bytes, gif);
|
|
assertEquals(scaled(broken.id), []);
|
|
|
|
if (!await hasFfmpeg()) {
|
|
const photo = insertDump({
|
|
userId: owner.id,
|
|
fileName: "p.png",
|
|
fileMime: "image/png",
|
|
fileData: PNG,
|
|
});
|
|
const itself = await get(`/api/dumps/${photo.id}/thumbnail`);
|
|
assertEquals(itself.headers.get("content-type"), "image/png");
|
|
assertEquals(itself.bytes, PNG);
|
|
return;
|
|
}
|
|
|
|
// A real image is scaled to the widest width without `w`, and to the width
|
|
// asked for with it, each copy kept under the upload's own path.
|
|
const photo = insertDump({
|
|
userId: owner.id,
|
|
fileName: "p.png",
|
|
fileMime: "image/png",
|
|
fileData: await makeImage(1400, 300),
|
|
});
|
|
const widest = await get(`/api/dumps/${photo.id}/thumbnail`);
|
|
assertEquals(widest.status, 200);
|
|
assertEquals(widest.headers.get("content-type"), "image/webp");
|
|
assertEquals(webpWidth(widest.bytes), 960);
|
|
const small = await get(`/api/dumps/${photo.id}/thumbnail?w=128`);
|
|
assertEquals(small.headers.get("content-type"), "image/webp");
|
|
assertEquals(webpWidth(small.bytes), 128);
|
|
// A width between two of the list's takes the one above.
|
|
const between = await get(`/api/dumps/${photo.id}/thumbnail?w=200`);
|
|
assertEquals(webpWidth(between.bytes), 320);
|
|
assertEquals(scaled(photo.id), [
|
|
`${photo.id}.w128.webp`,
|
|
`${photo.id}.w320.webp`,
|
|
`${photo.id}.w960.webp`,
|
|
]);
|
|
|
|
// A reader without WebP, as a mail client, asks for a PNG or a JPEG.
|
|
const compat = await get(`/api/dumps/${photo.id}/thumbnail?w=640&f=compat`);
|
|
assertEquals(compat.status, 200);
|
|
assertEquals(compat.headers.get("content-type"), "image/png");
|
|
// Its header states the width, on four bytes from the sixteenth.
|
|
assertEquals(new DataView(compat.bytes.buffer).getUint32(16), 640);
|
|
assertEquals(scaled(photo.id).includes(`${photo.id}.w640.png`), true);
|
|
|
|
// An animation keeps its frames.
|
|
const animated = insertDump({
|
|
userId: owner.id,
|
|
fileName: "anim.gif",
|
|
fileMime: "image/gif",
|
|
fileData: await makeImage(400, 300, "blue", "gif"),
|
|
});
|
|
const moving = await get(`/api/dumps/${animated.id}/thumbnail?w=128`);
|
|
assertEquals(moving.headers.get("content-type"), "image/webp");
|
|
assertEquals(webpWidth(moving.bytes), 128);
|
|
assertEquals(new TextDecoder().decode(moving.bytes).includes("ANMF"), true);
|
|
|
|
// The copies go with the dump.
|
|
assertEquals(
|
|
(await del(`/api/dumps/${photo.id}`, { auth: owner.auth })).status,
|
|
200,
|
|
);
|
|
assertEquals(scaled(photo.id), []);
|
|
});
|