check / check (push) Successful in 41s
A live photo, which Ente stores as one ZIP, is unpacked as it downloads into its image and its video, each `<fileID>.<ext>` with its extension from the ZIP, beside `<fileID>.livephoto.json`, which names the two. Both are checked against the recorded hash and renamed into place only when both are complete. The backup and the content cache count a live photo as stored only with both files, album folders link both, `quak get` writes both, and the content result gives the video as `videoPath`. A ZIP an earlier version stored is replaced. Model: opus-5-5
78 lines
3.0 KiB
TypeScript
78 lines
3.0 KiB
TypeScript
/**
|
|
* Live photo fixtures for the download, content cache, backup and CLI tests.
|
|
*
|
|
* Ente stores a live photo as one ZIP holding its image and its video, which
|
|
* Ente's clients name `image.<ext>` and `video.<ext>`. The ZIP is built here
|
|
* with fflate from small fixed bytes and encrypted the way the server serves a
|
|
* file under 4 MiB, as one secretstream chunk. `cdnSource` serves it to the
|
|
* real download layer, so a test checks what quak stores for a real one.
|
|
*/
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { zipSync } from "fflate";
|
|
|
|
import { ApiClient } from "../src/api/client.js";
|
|
import { encryptBlob, init, toBase64 } from "../src/crypto/index.js";
|
|
import {
|
|
makeDownloadContentSource,
|
|
type ContentSource,
|
|
} from "../src/library/content.js";
|
|
import type { EnteFile } from "../src/model/types.js";
|
|
|
|
export const IMAGE = new TextEncoder().encode("the still image");
|
|
export const VIDEO = new TextEncoder().encode("the few seconds of video");
|
|
|
|
// A live photo's ZIP; by default the entries an iPhone's live photo gets.
|
|
export const livePhotoZip = (
|
|
entries: Record<string, Uint8Array> = {
|
|
"image.heic": IMAGE,
|
|
"video.mov": VIDEO,
|
|
},
|
|
): Uint8Array => zipSync(entries);
|
|
|
|
const blake2b = (bytes: Uint8Array): string =>
|
|
createHash("blake2b512").update(bytes).digest("base64");
|
|
|
|
// The hash Ente's clients record for a live photo: the unkeyed BLAKE2b-512 of
|
|
// the image and of the video, each in standard base64, joined by a colon.
|
|
export const livePhotoHash = (image = IMAGE, video = VIDEO): string =>
|
|
`${blake2b(image)}:${blake2b(video)}`;
|
|
|
|
// `file` as a live photo whose original is `zip` and whose recorded hash is
|
|
// `hash`, and `body`, what the server serves for it: `zip` encrypted under the
|
|
// file's key and header.
|
|
export const asLivePhoto = async (
|
|
file: EnteFile,
|
|
zip = livePhotoZip(),
|
|
hash = livePhotoHash(),
|
|
): Promise<{ file: EnteFile; body: Uint8Array }> => {
|
|
await init();
|
|
const key = new Uint8Array(32).fill(file.id & 0xff);
|
|
const { header, ciphertext } = encryptBlob(zip, key);
|
|
return {
|
|
file: {
|
|
...file,
|
|
key,
|
|
metadata: { ...file.metadata, fileType: "livePhoto", hash },
|
|
file: { decryptionHeader: toBase64(header) },
|
|
},
|
|
body: ciphertext,
|
|
};
|
|
};
|
|
|
|
// A content source that downloads through the real download layer from a
|
|
// stand-in server, which serves `bodies` by file ID and a 404 for any other.
|
|
export const cdnSource = (bodies: Map<number, Uint8Array>): ContentSource =>
|
|
makeDownloadContentSource(
|
|
new ApiClient({
|
|
fetch: (async (url: string | URL) => {
|
|
const fileID = new URL(String(url)).searchParams.get("fileID");
|
|
const body = bodies.get(Number(fileID));
|
|
return body === undefined
|
|
? new Response("not found", { status: 404 })
|
|
: new Response(body);
|
|
}) as typeof globalThis.fetch,
|
|
retry: { attempts: 1 },
|
|
}),
|
|
);
|