Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c53a94ef6 |
No files matched your search
@@ -302,7 +302,7 @@ title, with unsafe characters replaced (see "File names from the server"). With
|
|||||||
original is kept in the cache as well.
|
original is kept in the cache as well.
|
||||||
|
|
||||||
A file ID that no collection holds gives `File <id> not found`, and the exit
|
A file ID that no collection holds gives `File <id> not found`, and the exit
|
||||||
status is 1. The option `--collection <id>` is accepted and ignored.
|
status is 1.
|
||||||
|
|
||||||
A live photo is a still image and a short video that Ente stores as one file.
|
A live photo is a still image and a short video that Ente stores as one file.
|
||||||
`get` writes both, each named after the title with its own extension, as Ente's
|
`get` writes both, each named after the title with its own extension, as Ente's
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ program
|
|||||||
.description("Download and decrypt a single file")
|
.description("Download and decrypt a single file")
|
||||||
.argument("<fileID>", "File ID (from `quak files`)")
|
.argument("<fileID>", "File ID (from `quak files`)")
|
||||||
.option("--out <path>", "Output file path")
|
.option("--out <path>", "Output file path")
|
||||||
.option("--collection <id>", "Accepted for compatibility; ignored")
|
|
||||||
.action((fileID: string, opts: { out?: string }) =>
|
.action((fileID: string, opts: { out?: string }) =>
|
||||||
run(getCommand(context(), fileID, opts)),
|
run(getCommand(context(), fileID, opts)),
|
||||||
);
|
);
|
||||||
@@ -133,7 +132,6 @@ program
|
|||||||
.description("Download and decrypt a thumbnail")
|
.description("Download and decrypt a thumbnail")
|
||||||
.argument("<fileID>", "File ID (from `quak files`)")
|
.argument("<fileID>", "File ID (from `quak files`)")
|
||||||
.option("--out <path>", "Output file path")
|
.option("--out <path>", "Output file path")
|
||||||
.option("--collection <id>", "Accepted for compatibility; ignored")
|
|
||||||
.action((fileID: string, opts: { out?: string }) =>
|
.action((fileID: string, opts: { out?: string }) =>
|
||||||
run(getThumbCommand(context(), fileID, opts)),
|
run(getThumbCommand(context(), fileID, opts)),
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-8
@@ -354,13 +354,7 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// `contentMD5` is the value given to `getUploadURL`. The server signs it
|
async putFile(presignedURL: string, data: Uint8Array): Promise<void> {
|
||||||
// into the URL, and storage refuses a PUT without that `Content-MD5`.
|
|
||||||
async putFile(
|
|
||||||
presignedURL: string,
|
|
||||||
data: Uint8Array,
|
|
||||||
contentMD5: string,
|
|
||||||
): Promise<void> {
|
|
||||||
// Idempotent despite being a write: a presigned PUT stores one whole
|
// Idempotent despite being a write: a presigned PUT stores one whole
|
||||||
// object at one key in one request, so replaying it either overwrites
|
// object at one key in one request, so replaying it either overwrites
|
||||||
// the same bytes or lands them for the first time. There is no partial
|
// the same bytes or lands them for the first time. There is no partial
|
||||||
@@ -376,7 +370,6 @@ export class ApiClient {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
"Content-Length": String(data.length),
|
"Content-Length": String(data.length),
|
||||||
"Content-MD5": contentMD5,
|
|
||||||
},
|
},
|
||||||
body: data,
|
body: data,
|
||||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||||
|
|||||||
+1
-1
@@ -325,7 +325,7 @@ export const fixMissingThumbnails = async (
|
|||||||
ciphertext.length,
|
ciphertext.length,
|
||||||
md5,
|
md5,
|
||||||
);
|
);
|
||||||
await api.putFile(url, ciphertext, md5);
|
await api.putFile(url, ciphertext);
|
||||||
await api.updateThumbnail(file.id, objectKey, toBase64(header));
|
await api.updateThumbnail(file.id, objectKey, toBase64(header));
|
||||||
|
|
||||||
log(`[${collectionName}] Thumbnail uploaded for ${title}`);
|
log(`[${collectionName}] Thumbnail uploaded for ${title}`);
|
||||||
|
|||||||
+4
-10
@@ -780,7 +780,7 @@ describe("ApiClient timeouts", () => {
|
|||||||
|
|
||||||
await client.getJSON("/a");
|
await client.getJSON("/a");
|
||||||
await client.postJSON("/b", {});
|
await client.postJSON("/b", {});
|
||||||
await client.putFile("https://s3.example/x", new Uint8Array([1]), "m");
|
await client.putFile("https://s3.example/x", new Uint8Array([1]));
|
||||||
await client.putJSON("/c", {});
|
await client.putJSON("/c", {});
|
||||||
|
|
||||||
for (const call of calls) {
|
for (const call of calls) {
|
||||||
@@ -825,8 +825,7 @@ describe("ApiClient timeouts", () => {
|
|||||||
[
|
[
|
||||||
"putFile",
|
"putFile",
|
||||||
() => new Response(null, { status: 200 }),
|
() => new Response(null, { status: 200 }),
|
||||||
(c) =>
|
(c) => c.putFile("https://s3.example/x", new Uint8Array([1])),
|
||||||
c.putFile("https://s3.example/x", new Uint8Array([1]), "m"),
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"getFileStream",
|
"getFileStream",
|
||||||
@@ -1050,7 +1049,7 @@ describe("ApiClient error typing", () => {
|
|||||||
const client = new ApiClient({ fetch, retry: noWait });
|
const client = new ApiClient({ fetch, retry: noWait });
|
||||||
|
|
||||||
const err: unknown = await client
|
const err: unknown = await client
|
||||||
.putFile("https://s3.example/obj", new Uint8Array([1, 2]), "m")
|
.putFile("https://s3.example/obj", new Uint8Array([1, 2]))
|
||||||
.catch((e: unknown) => e);
|
.catch((e: unknown) => e);
|
||||||
|
|
||||||
expect(err).toBeInstanceOf(ApiError);
|
expect(err).toBeInstanceOf(ApiError);
|
||||||
@@ -1068,11 +1067,7 @@ describe("ApiClient error typing", () => {
|
|||||||
);
|
);
|
||||||
const client = new ApiClient({ fetch, retry: noWait });
|
const client = new ApiClient({ fetch, retry: noWait });
|
||||||
|
|
||||||
await client.putFile(
|
await client.putFile("https://s3.example/obj", new Uint8Array([1, 2]));
|
||||||
"https://s3.example/obj",
|
|
||||||
new Uint8Array([1, 2]),
|
|
||||||
"m",
|
|
||||||
);
|
|
||||||
expect(calls).toHaveLength(2);
|
expect(calls).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1282,7 +1277,6 @@ describe("ApiClient onRequest", () => {
|
|||||||
await client.putFile(
|
await client.putFile(
|
||||||
"https://s3.example/obj?X-Amz-Signature=abc",
|
"https://s3.example/obj?X-Amz-Signature=abc",
|
||||||
new Uint8Array([1, 2]),
|
new Uint8Array([1, 2]),
|
||||||
"m",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(requests).toEqual([
|
expect(requests).toEqual([
|
||||||
|
|||||||
+2
-13
@@ -80,20 +80,13 @@ describe("ApiClient.putFile", () => {
|
|||||||
const client = new ApiClient({ fetch, authToken: "secret-tok" });
|
const client = new ApiClient({ fetch, authToken: "secret-tok" });
|
||||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||||
|
|
||||||
await client.putFile(
|
await client.putFile("https://s3.example.com/presigned?sig=abc", data);
|
||||||
"https://s3.example.com/presigned?sig=abc",
|
|
||||||
data,
|
|
||||||
"md5base64==",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(calls[0]!.url).toBe("https://s3.example.com/presigned?sig=abc");
|
expect(calls[0]!.url).toBe("https://s3.example.com/presigned?sig=abc");
|
||||||
expect(calls[0]!.init?.method).toBe("PUT");
|
expect(calls[0]!.init?.method).toBe("PUT");
|
||||||
const headers = new Headers(calls[0]!.init?.headers as HeadersInit);
|
const headers = new Headers(calls[0]!.init?.headers as HeadersInit);
|
||||||
expect(headers.get("Content-Type")).toBe("application/octet-stream");
|
expect(headers.get("Content-Type")).toBe("application/octet-stream");
|
||||||
expect(headers.get("Content-Length")).toBe("5");
|
expect(headers.get("Content-Length")).toBe("5");
|
||||||
// The server signs the MD5 into the URL; storage refuses the PUT
|
|
||||||
// without it.
|
|
||||||
expect(headers.get("Content-MD5")).toBe("md5base64==");
|
|
||||||
// Must NOT leak auth headers to S3
|
// Must NOT leak auth headers to S3
|
||||||
expect(headers.has("X-Auth-Token")).toBe(false);
|
expect(headers.has("X-Auth-Token")).toBe(false);
|
||||||
expect(headers.has("X-Client-Package")).toBe(false);
|
expect(headers.has("X-Client-Package")).toBe(false);
|
||||||
@@ -106,11 +99,7 @@ describe("ApiClient.putFile", () => {
|
|||||||
const client = new ApiClient({ fetch });
|
const client = new ApiClient({ fetch });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.putFile(
|
client.putFile("https://s3.example.com/bad", new Uint8Array(10)),
|
||||||
"https://s3.example.com/bad",
|
|
||||||
new Uint8Array(10),
|
|
||||||
"md5base64==",
|
|
||||||
),
|
|
||||||
).rejects.toThrow(/403/);
|
).rejects.toThrow(/403/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-2
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for the retry options `bin/quak.ts` loads the saved session with
|
* Tests for the retry options `bin/quak.ts` loads the saved session with
|
||||||
* (`quak backup` gets the unattended ones, every other command the default),
|
* (`quak backup` gets the unattended ones, every other command the default),
|
||||||
* each with the callbacks that log its requests and retries, and for
|
* each with the callbacks that log its requests and retries, for `quak files`
|
||||||
* `quak files` taking its collection ID as an optional argument.
|
* taking its collection ID as an optional argument, and for `quak get` and
|
||||||
|
* `quak get-thumb` refusing `--collection`.
|
||||||
*
|
*
|
||||||
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
|
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
|
||||||
* replaced by one that records the options it is given and reports no session,
|
* replaced by one that records the options it is given and reports no session,
|
||||||
@@ -105,3 +106,14 @@ describe("bin/quak.ts files", () => {
|
|||||||
expect(await quak("files", "7")).toMatch(/^Not logged in/);
|
expect(await quak("files", "7")).toMatch(/^Not logged in/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("bin/quak.ts get and get-thumb", () => {
|
||||||
|
it.each(["get", "get-thumb"])(
|
||||||
|
"%s refuses --collection as an unknown option",
|
||||||
|
async (command) => {
|
||||||
|
expect(await quak(command, "1", "--collection", "2")).toMatch(
|
||||||
|
/^error: unknown option '--collection'/,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -569,14 +569,14 @@ describe("files", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("get and get-thumb", () => {
|
describe("get and get-thumb", () => {
|
||||||
it("get finds a file in any album without --collection", async () => {
|
it("get finds a file in any album", async () => {
|
||||||
const out = join(root, "diagram.png");
|
const out = join(root, "diagram.png");
|
||||||
expect(await getCommand(context(), "200", { out })).toBe(0);
|
expect(await getCommand(context(), "200", { out })).toBe(0);
|
||||||
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
|
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
|
||||||
expect(stderr.text).toBe(`7 bytes -> ${out}\n`);
|
expect(stderr.text).toBe(`7 bytes -> ${out}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("get-thumb finds a file in any album without --collection", async () => {
|
it("get-thumb finds a file in any album", async () => {
|
||||||
const out = join(root, "thumb.jpg");
|
const out = join(root, "thumb.jpg");
|
||||||
expect(await getThumbCommand(context(), "200", { out })).toBe(0);
|
expect(await getThumbCommand(context(), "200", { out })).toBe(0);
|
||||||
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
|
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
|
||||||
|
|||||||
@@ -273,7 +273,6 @@ const buildThumbMock = async (opts?: {
|
|||||||
|
|
||||||
const buildThumbFetch = (m: ThumbMockState) => {
|
const buildThumbFetch = (m: ThumbMockState) => {
|
||||||
let srpServer: SrpServer;
|
let srpServer: SrpServer;
|
||||||
let signedMD5: string | undefined;
|
|
||||||
|
|
||||||
return (async (
|
return (async (
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
@@ -366,9 +365,8 @@ const buildThumbFetch = (m: ThumbMockState) => {
|
|||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload URL minting. The real server signs the MD5 into the URL.
|
// Upload URL minting
|
||||||
if (path === "/files/upload-url") {
|
if (path === "/files/upload-url") {
|
||||||
signedMD5 = JSON.parse(init?.body as string).contentMD5;
|
|
||||||
return json({
|
return json({
|
||||||
objectKey: `42/thumb-${Date.now()}`,
|
objectKey: `42/thumb-${Date.now()}`,
|
||||||
url: "https://s3.mock.test/presigned-put",
|
url: "https://s3.mock.test/presigned-put",
|
||||||
@@ -377,11 +375,6 @@ const buildThumbFetch = (m: ThumbMockState) => {
|
|||||||
|
|
||||||
// Presigned PUT (S3 upload)
|
// Presigned PUT (S3 upload)
|
||||||
if (url.startsWith("https://s3.mock.test/")) {
|
if (url.startsWith("https://s3.mock.test/")) {
|
||||||
// Storage refuses a PUT that lacks the signed `Content-MD5`.
|
|
||||||
const headers = new Headers(init?.headers);
|
|
||||||
if (headers.get("Content-MD5") !== signedMD5) {
|
|
||||||
return new Response("AccessDenied", { status: 400 });
|
|
||||||
}
|
|
||||||
const body = init?.body;
|
const body = init?.body;
|
||||||
// Store the uploaded bytes for later inspection
|
// Store the uploaded bytes for later inspection
|
||||||
if (body instanceof Uint8Array) {
|
if (body instanceof Uint8Array) {
|
||||||
|
|||||||
Reference in new issue
Block a user