Phase 6 green: download and decrypt files to disk
downloadFile streams the encrypted body from the CDN, buffers it to the 4 MiB + 17 encrypted chunk boundary, decrypts each chunk via secretstream pull, and writes the concatenated plaintext to disk. downloadThumbnail does the same for the thumbnail CDN. 4 unit tests (single-chunk, large single-chunk, filename fallback, thumbnail) + live integration test that downloads a real 472 KB JPEG from the dev account and verifies it lands on disk. Uses mkdtempSync for temp directories (not manual timestamp paths).
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
// Stub: see the README "Development workflow" section for TDD policy.
|
||||
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import {
|
||||
fromBase64,
|
||||
initStreamPull,
|
||||
pullStreamChunk,
|
||||
STREAM_CHUNK_OVERHEAD,
|
||||
STREAM_CHUNK_SIZE,
|
||||
} from "../crypto/index.js";
|
||||
import type { ApiClient } from "../api/client.js";
|
||||
import type { EnteFile } from "../model/types.js";
|
||||
|
||||
@@ -8,18 +14,77 @@ export interface DownloadResult {
|
||||
bytesWritten: number;
|
||||
}
|
||||
|
||||
const ENC_CHUNK_SIZE = STREAM_CHUNK_SIZE + STREAM_CHUNK_OVERHEAD;
|
||||
|
||||
const streamDecrypt = async (
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
header: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Promise<Uint8Array> => {
|
||||
const state = initStreamPull(header, key);
|
||||
const reader = stream.getReader();
|
||||
let buffer = new Uint8Array(0);
|
||||
const plainChunks: Uint8Array[] = [];
|
||||
let totalPlain = 0;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (value) {
|
||||
const merged = new Uint8Array(buffer.length + value.length);
|
||||
merged.set(buffer);
|
||||
merged.set(value, buffer.length);
|
||||
buffer = merged;
|
||||
}
|
||||
|
||||
while (buffer.length >= ENC_CHUNK_SIZE) {
|
||||
const encChunk = buffer.slice(0, ENC_CHUNK_SIZE);
|
||||
buffer = buffer.slice(ENC_CHUNK_SIZE);
|
||||
const { plaintext } = pullStreamChunk(state, encChunk);
|
||||
plainChunks.push(plaintext);
|
||||
totalPlain += plaintext.length;
|
||||
}
|
||||
|
||||
if (done) {
|
||||
if (buffer.length > 0) {
|
||||
const { plaintext } = pullStreamChunk(state, buffer);
|
||||
plainChunks.push(plaintext);
|
||||
totalPlain += plaintext.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalPlain);
|
||||
let offset = 0;
|
||||
for (const chunk of plainChunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const downloadFile = async (
|
||||
_api: ApiClient,
|
||||
_file: EnteFile,
|
||||
_outPath?: string,
|
||||
api: ApiClient,
|
||||
file: EnteFile,
|
||||
outPath?: string,
|
||||
): Promise<DownloadResult> => {
|
||||
throw new Error("download.downloadFile not implemented");
|
||||
const resolvedPath = outPath ?? file.metadata.title;
|
||||
const stream = await api.getFileStream(file.id);
|
||||
const header = fromBase64(file.file.decryptionHeader);
|
||||
const plaintext = await streamDecrypt(stream, header, file.key);
|
||||
await writeFile(resolvedPath, plaintext);
|
||||
return { path: resolvedPath, bytesWritten: plaintext.length };
|
||||
};
|
||||
|
||||
export const downloadThumbnail = async (
|
||||
_api: ApiClient,
|
||||
_file: EnteFile,
|
||||
_outPath?: string,
|
||||
api: ApiClient,
|
||||
file: EnteFile,
|
||||
outPath?: string,
|
||||
): Promise<DownloadResult> => {
|
||||
throw new Error("download.downloadThumbnail not implemented");
|
||||
const resolvedPath = outPath ?? `thumb_${file.metadata.title}`;
|
||||
const stream = await api.getThumbnailStream(file.id);
|
||||
const header = fromBase64(file.thumbnail.decryptionHeader);
|
||||
const plaintext = await streamDecrypt(stream, header, file.key);
|
||||
await writeFile(resolvedPath, plaintext);
|
||||
return { path: resolvedPath, bytesWritten: plaintext.length };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user