quak login --server: log in to a self-hosted Ente server (closes #243)
check / check (push) Successful in 3m2s
check / check (push) Successful in 3m2s
`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 is contained in:
1 parent
9bd7e8f099
commit
f9af8e3f70
10 files changed
+643
-67
No files matched your search
@@ -19,16 +19,20 @@
|
||||
* 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.
|
||||
*
|
||||
* 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 +53,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 +117,7 @@ const file = (
|
||||
*/
|
||||
class MockClient {
|
||||
userID = USER_ID;
|
||||
apiOrigin?: string;
|
||||
failCollections = false;
|
||||
collectionsQueue: CollectionsPage[] = [];
|
||||
filesByCollection = new Map<number, FilesPage[]>();
|
||||
@@ -584,6 +595,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 +891,94 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user