Author SHA1 Message Date
sneak 6f8f85fff5 quak login --server: log in to a self-hosted Ente server (closes #243)
check / check (push) Successful in 3m29s
`quak login` takes the server's API address from `--server` or
`QUAK_SERVER`. `isServerAddress` refuses anything but an http or https
URL with no query or fragment, there and in `Client.fromJSON`. The
client's snapshot keeps a non-default `apiOrigin`, so the session file
names the server and every later command uses it.

Two servers can give two accounts, and two files, the same ID. A
self-hosted account's default cache directory therefore names the
server, and `metadata.json` records it: `Library.open` deletes a cache
from another server whole. `originalOf` and `thumbnailOf` record the
account in a cache that names none before storing a file, and `open`
treats a cache with no collection as empty. This fixes
#254 (closes #254).

Model: opus-5-5
2026-11-13 21:19:24 +00:00
3 changed files with 71 additions and 3 deletions

No files matched your search

+15
View File
@@ -445,6 +445,21 @@ describe("login", () => {
); );
}, },
); );
it("refuses an empty QUAK_SERVER before asking for anything", async () => {
vi.stubEnv("QUAK_SERVER", "");
const ctx = loginContext(async () => {
throw new Error("login must not be called");
});
expect(await loginCommand(ctx, {})).toBe(1);
expect(prompts).toEqual([]);
expect(stderr.text).toBe(
`Invalid server address "": it must be an http or https URL ` +
`with no query or fragment\n`,
);
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
});
}); });
// These use a real client read from the session file, over a fake API that // These use a real client read from the session file, over a fake API that
+17 -2
View File
@@ -55,10 +55,25 @@ describe("Client.toJSON", () => {
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot); expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
}); });
it("names the server apiOptions gave fromJSON", () => { it("names the server apiOptions gave fromJSON", async () => {
const apiOrigin = "https://ente.example.com"; const apiOrigin = "https://ente.example.com";
const client = Client.fromJSON(validSnapshot(), { apiOrigin }); const urls: string[] = [];
const client = Client.fromJSON(validSnapshot(), {
apiOrigin,
fetch: async (input) => {
urls.push(String(input));
return new Response(JSON.stringify({ collections: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
});
expect(client.toJSON().apiOrigin).toBe(apiOrigin); expect(client.toJSON().apiOrigin).toBe(apiOrigin);
await client.listCollections();
expect(urls).toEqual([
"https://ente.example.com/collections/v2?sinceTime=0",
]);
}); });
it("throws instead of emitting a snapshot without a token", () => { it("throws instead of emitting a snapshot without a token", () => {
+39 -1
View File
@@ -33,7 +33,8 @@
* 10. A cache another account filled starts empty. One filled from another * 10. A cache another account filled starts empty. One filled from another
* server also loses its originals and thumbnails, since two servers can * server also loses its originals and thumbnails, since two servers can
* give two files the same ID. `thumbnailOf` on an empty cache saves the * give two files the same ID. `thumbnailOf` on an empty cache saves the
* account's user ID and server. * account's user ID and server. A refresh saves the account's server, so a
* self-hosted account keeps its own cache from one `open()` to the next.
* *
* The client is a mock: no crypto, no network. It serves scripted pages and * The client is a mock: no crypto, no network. It serves scripted pages and
* records the `sinceTime` each call carried so cursor threading is provable. * records the `sinceTime` each call carried so cursor threading is provable.
@@ -1016,4 +1017,41 @@ describe("Library.open and background refresh", () => {
expect(saved.userID).toBe(USER_ID); expect(saved.userID).toBe(USER_ID);
expect(saved.apiOrigin).toBe("https://ente.example.com"); expect(saved.apiOrigin).toBe("https://ente.example.com");
}); });
// A refresh saves the account's server in `metadata.json`. Without it,
// `open()` would take a self-hosted account's own cache for another
// server's and delete it.
it("keeps a self-hosted account's own cache from one open to the next", async () => {
const client = new MockClient();
client.apiOrigin = "https://ente.example.com";
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
const first = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: Infinity,
});
await first.close();
mkdirSync(join(cacheDirectory, "originals"));
writeFileSync(join(cacheDirectory, "originals", "1001.jpg"), "1001");
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: Infinity,
});
try {
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
expect(
existsSync(join(cacheDirectory, "originals", "1001.jpg")),
).toBe(true);
} finally {
await lib.close();
}
// The second open's refresh started from the saved cursor.
expect(client.collectionsSinceTimes).toEqual([0, 100]);
});
}); });