quak login --server: log in to a self-hosted Ente server (closes #243)
check / check (push) Successful in 5m0s

`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
This commit was merged in pull request #253.
This commit is contained in:
clawbot committed 2026-11-13 22:57:49 +01:00
1 parent 9bd7e8f099
commit 2492946e9d
10 files changed
+768 -68

No files matched your search

+218 -6
View File
@@ -19,16 +19,22 @@
* refresh fails (offline start from cache).
* 5. `close()` stops the timer and is idempotent, and its promise resolves
* only once an in-flight refresh has written the cache file.
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id.
* 7. `open()` branches on the cache: an empty cache awaits the first refresh
* (it has nothing to serve yet); an existing cache serves its copy at once
* and refreshes in the background, so a slow or dead server never stalls
* opening.
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id,
* with a self-hosted server's address before the user id.
* 7. `open()` branches on the cache: a cache that holds no collection awaits
* the first refresh (it has nothing to serve yet); an existing cache serves
* its copy at once and refreshes in the background, so a slow or dead
* server never stalls opening.
* 8. A save failure that leaves RAM ahead of disk keeps `status().lastError`
* set and keeps retrying the write; a later empty refresh does not clear it.
* 9. With `refreshOnOpen: false`, `open()` starts no refresh at all, and a
* refresh reports through `onProgress` each collection whose files it
* fetched.
* 10. A cache another account filled starts empty. One filled from another
* server also loses its originals and thumbnails, since two servers can
* give two files the same ID. `thumbnailOf` on an empty cache saves the
* 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
* records the `sinceTime` each call carried so cursor threading is provable.
@@ -49,7 +55,13 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import envPaths from "env-paths";
@@ -107,6 +119,7 @@ const file = (
*/
class MockClient {
userID = USER_ID;
apiOrigin?: string;
failCollections = false;
collectionsQueue: CollectionsPage[] = [];
filesByCollection = new Map<number, FilesPage[]>();
@@ -584,6 +597,44 @@ describe("Library.open and background refresh", () => {
}
});
// `originalOf` on an empty cache leaves such a cache behind: it names the
// account before it stores the original.
it("awaits the first refresh on a cache that names the account but holds no collection", async () => {
const seed = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
seed.userID = USER_ID;
await seed.save();
let releaseFirstFetch: (page: CollectionsPage) => void = () => {};
const gate = new Promise<CollectionsPage>((resolve) => {
releaseFirstFetch = resolve;
});
const client = new MockClient();
client.collectionsSince = async () => gate;
let opened = false;
const openPromise = Library.open({ client, cacheDirectory }).then(
(l) => {
opened = true;
return l;
},
);
await new Promise((r) => setTimeout(r, 20));
expect(opened).toBe(false);
releaseFirstFetch({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
const lib = await openPromise;
try {
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
} finally {
await lib.close();
}
});
// A caller that asks for each refresh it needs, as the CLI's read commands
// do, opens without one: on an empty cache the refresh `open()` would
// await fetches the files of every collection, which `quak collections`
@@ -842,4 +893,165 @@ describe("Library.open and background refresh", () => {
else process.env.XDG_CACHE_HOME = prev;
}
});
it("puts a self-hosted server's address, URL-encoded, before the user id in the default cacheDirectory", async () => {
const xdg = join(dir, "xdg-cache");
const prev = process.env.XDG_CACHE_HOME;
process.env.XDG_CACHE_HOME = xdg;
try {
const client = new MockClient();
client.apiOrigin = "https://ente.example.com:8443/api";
const lib = await Library.open({ client });
try {
expect(lib.cacheDirectory).toBe(
join(
envPaths("quak", { suffix: "" }).cache,
"https%3A%2F%2Fente.example.com%3A8443%2Fapi",
String(USER_ID),
),
);
expect(lib.cacheDirectory.startsWith(xdg)).toBe(true);
} finally {
await lib.close();
}
} finally {
if (prev === undefined) delete process.env.XDG_CACHE_HOME;
else process.env.XDG_CACHE_HOME = prev;
}
});
// A cache that account USER_ID on `apiOrigin` filled: its records, and an
// original and a thumbnail of file 1001.
const fillCache = async (apiOrigin?: string): Promise<void> => {
const seed = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
seed.userID = USER_ID;
seed.apiOrigin = apiOrigin;
seed.collectionsSinceTime = 500;
seed.putCollection(collection(1, 400));
seed.putFile(file(1001, 1, 400));
await seed.save();
for (const name of ["originals", "thumbnails"]) {
mkdirSync(join(cacheDirectory, name));
writeFileSync(join(cacheDirectory, name, "1001.jpg"), "1001");
}
};
it("deletes the whole cache an account on another server filled", async () => {
await fillCache("https://ente.example.com");
const client = new MockClient();
client.apiOrigin = "https://photos.example.org";
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: 3600,
});
try {
expect(client.collectionsSinceTimes).toEqual([0]);
expect(lib.listCollections()).toEqual([]);
for (const name of ["originals", "thumbnails"]) {
expect(existsSync(join(cacheDirectory, name, "1001.jpg"))).toBe(
false,
);
}
} finally {
await lib.close();
}
});
// A server gives each file its own ID, so another account's cached file
// is never served for this account's.
it("keeps the originals and thumbnails another account on the same server cached", async () => {
await fillCache();
const client = new MockClient();
client.userID = USER_ID + 1;
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: 3600,
});
try {
expect(client.collectionsSinceTimes).toEqual([0]);
expect(lib.listCollections()).toEqual([]);
for (const name of ["originals", "thumbnails"]) {
expect(existsSync(join(cacheDirectory, name, "1001.jpg"))).toBe(
true,
);
}
} finally {
await lib.close();
}
});
// `quak get-thumb` on an empty cache stores a thumbnail before any refresh
// runs. `open()` for an account on another server deletes that thumbnail
// only if the cache names the account that stored it.
it("thumbnailOf on an empty cache saves the account's user ID and server in metadata.json", async () => {
const client = new MockClient();
client.apiOrigin = "https://ente.example.com";
const lib = await Library.open({
client,
cacheDirectory,
refreshOnOpen: false,
refreshIntervalSeconds: Infinity,
contentSource: {
original: async () => {
throw new Error("original not expected");
},
thumbnail: async ({ destination }) => {
writeFileSync(destination, "thumb");
return { bytesWritten: 5 };
},
},
});
try {
await lib.thumbnailOf(file(1001, 1, 400));
} finally {
await lib.close();
}
const saved = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
expect(saved.userID).toBe(USER_ID);
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]);
});
});