One album or file quak cannot decrypt no longer stops a refresh or a backup (closes #181)
check / check (push) Waiting to run

A file or album whose key, metadata or name does not decrypt used to
throw out of the refresh, so quak backup stopped before storing
anything, every run. Client.collectionsSince and filesSince now return
such a row in undecryptable with the reason, the refresh leaves it out,
and the store keeps the list in metadata.json until a later refresh
decrypts or deletes the record. lib.backup() records each such file in
failures.json unless a copy in another album decrypted, in which case it
stores the original from that copy. Each such album is listed in
BackupResult.undecryptableAlbums and printed as one FAILED album line.
quak backup exits 1 while either remains. An album or file that no
longer decrypts loses its album folder, JSON and links; its original
stays.

Model: opus-5-5
This commit was merged in pull request #188.
This commit is contained in:
2026-10-14 07:08:57 +02:00
parent 288825b11a
commit bb3b5cd2da
11 changed files with 956 additions and 77 deletions
+53 -26
View File
@@ -44,20 +44,28 @@ export interface ClientSnapshot {
// The result of a resumable enumeration. Live decrypted records and deleted
// ids are kept apart on purpose: a tombstone carries no key or metadata to
// decrypt, so it is a bare id rather than a hollowed-out record. `cursor` is
// the max `updationTime` seen, to pass back into the next call.
// decrypt, so it is a bare id rather than a hollowed-out record. A live row
// that cannot be decrypted, or whose metadata cannot be parsed, is in
// `undecryptable` with the reason instead, so one bad row does not fail the
// call; absent means there was none. `cursor` is the max `updationTime` seen,
// those rows included, to pass back into the next call.
export interface CollectionsPage {
collections: Collection[];
deleted: number[];
undecryptable?: { id: number; error: string }[];
cursor: number;
}
export interface FilesPage {
files: EnteFile[];
deleted: number[];
undecryptable?: { id: number; error: string }[];
cursor: number;
}
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
export class Client {
private readonly api: ApiClient;
private readonly email: string;
@@ -237,11 +245,12 @@ export class Client {
}
// Enumerate collections changed since `sinceTime`. Live collections are
// decrypted; tombstoned ones (isDeleted) are surfaced as bare ids. The
// returned cursor is the max `updationTime` seen — including tombstones, so
// the next sync resumes past them — falling back to `sinceTime` when the
// response is empty. `/collections/v2` returns the whole changed set in one
// response, so there is no pagination here.
// decrypted; tombstoned ones (isDeleted) are surfaced as bare ids, and
// ones that do not decrypt as ids with the reason. The returned cursor is
// the max `updationTime` seen — including tombstones, so the next sync
// resumes past them — falling back to `sinceTime` when the response is
// empty. `/collections/v2` returns the whole changed set in one response,
// so there is no pagination here.
async collectionsSince(args: {
sinceTime: number;
}): Promise<CollectionsPage> {
@@ -254,31 +263,40 @@ export class Client {
const collections: Collection[] = [];
const deleted: number[] = [];
const undecryptable: { id: number; error: string }[] = [];
let cursor = args.sinceTime;
for (const raw of raws) {
if (raw.isDeleted) {
deleted.push(raw.id);
} else {
collections.push(
decryptCollection(
raw,
{
masterKey: this.masterKey,
publicKey: this.publicKey,
secretKey: this.secretKey,
},
this.userID,
),
);
try {
collections.push(
decryptCollection(
raw,
{
masterKey: this.masterKey,
publicKey: this.publicKey,
secretKey: this.secretKey,
},
this.userID,
),
);
} catch (err) {
undecryptable.push({
id: raw.id,
error: errorMessage(err),
});
}
}
if (raw.updationTime > cursor) cursor = raw.updationTime;
}
return { collections, deleted, cursor };
return { collections, deleted, undecryptable, cursor };
}
// Enumerate a collection's files changed since `sinceTime`, paginating the
// diff from that cursor. Live rows are decrypted; tombstoned ones are
// surfaced as bare ids. Returns the final cursor to resume from.
// surfaced as bare ids, and ones that do not decrypt or parse as ids with
// the reason. Returns the final cursor to resume from.
async filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
@@ -288,6 +306,7 @@ export class Client {
const { collectionID, collectionKey } = args;
const files: EnteFile[] = [];
const deleted: number[] = [];
const undecryptable: { id: number; error: string }[] = [];
let cursor = args.sinceTime;
for (;;) {
const { diff, hasMore } = await this.api.getJSON<{
@@ -300,7 +319,14 @@ export class Client {
if (raw.isDeleted) {
deleted.push(raw.id);
} else {
files.push(decryptFile(raw, collectionKey));
try {
files.push(decryptFile(raw, collectionKey));
} catch (err) {
undecryptable.push({
id: raw.id,
error: errorMessage(err),
});
}
}
if (raw.updationTime > pageMax) pageMax = raw.updationTime;
}
@@ -321,18 +347,19 @@ export class Client {
}
cursor = pageMax;
}
return { files, deleted, cursor };
return { files, deleted, undecryptable, cursor };
}
// Whole-account listing: every live collection, deletions hidden. A thin
// wrapper over `collectionsSince` from the beginning of time.
// Whole-account listing: every live collection that decrypts, deletions
// hidden. A thin wrapper over `collectionsSince` from the beginning of
// time.
async listCollections(): Promise<Collection[]> {
const { collections } = await this.collectionsSince({ sinceTime: 0 });
return collections;
}
// Every live file in a collection, deletions hidden. A thin wrapper over
// `filesSince` from the beginning of time.
// Every live file in a collection that decrypts, deletions hidden. A thin
// wrapper over `filesSince` from the beginning of time.
async listFiles(
collectionID: number,
collectionKey: Uint8Array,