Store live photos as their image and their video (closes #107)
check / check (push) Successful in 41s
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
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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 },
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user