@needmoretruth/nmts-cli 0.38.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ // Which answer an S3 request gets: the signature first, then the bucket, then the verb.
2
+ //
3
+ // ⛔ WHAT IS NOT ANSWERED IS REFUSED, LOUDLY. An S3 client that asks for something this gateway does
4
+ // not do gets 501 and a sentence naming what it does do. The alternative -- answering an empty
5
+ // listing, or a 200 with nothing behind it -- is how a backup tool reports success over a backup
6
+ // that never happened.
7
+ import { listObjects, objectsOf, folderPrefixesOf, MAX_KEYS_LIMIT } from "./listing.js";
8
+ import { handleMultipart, isMultipartRequest } from "./multipart.js";
9
+ import { responseSink } from "./response-sink.js";
10
+ import { isKeyConflict } from "./same-file.js";
11
+ import { STREAMING_PAYLOAD, STREAMING_PAYLOAD_TRAILER, verifyAgainst, } from "./sigv4.js";
12
+ import { errorXml, listBucketsXml, listObjectsXml } from "./xml.js";
13
+ /**
14
+ * What a caller signed with a pair it may not use here.
15
+ *
16
+ * ⛔ THE SAME ANSWER WHETHER OR NOT THE BUCKET EXISTS, which is why the restriction is checked
17
+ * before the resolver is asked. Answering `NoSuchBucket` for a name the caller may not touch
18
+ * would turn this gateway into a way of asking "does this business have a customer called…",
19
+ * one guess at a time.
20
+ */
21
+ const NOT_YOURS = "That access key may not use that bucket.";
22
+ export function fail(res, status, code, message, resource) {
23
+ const body = errorXml(code, message, resource);
24
+ res.writeHead(status, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
25
+ res.end(body);
26
+ }
27
+ /** `/drive/photos/a.jpg` → bucket `drive`, key `photos/a.jpg`. */
28
+ function splitPath(pathname) {
29
+ const trimmed = pathname.replace(/^\//, "");
30
+ const at = trimmed.indexOf("/");
31
+ if (at < 0)
32
+ return { bucket: decodeURIComponent(trimmed), key: "" };
33
+ return { bucket: decodeURIComponent(trimmed.slice(0, at)), key: decodeURIComponent(trimmed.slice(at + 1)) };
34
+ }
35
+ function headerOf(req, name) {
36
+ const raw = req.headers[name];
37
+ return Array.isArray(raw) ? raw.join(",") : raw;
38
+ }
39
+ /** The one sentence a write gets from `nmts s3` when this machine has not agreed to spending. */
40
+ function readOnlyOnThisMachine() {
41
+ return ("This gateway is read only. Uploading spends credits, and this machine has not agreed to " +
42
+ "spending — `nmts consent grant spend`, run by the person whose account this is, is what " +
43
+ "changes that. Nothing was written.");
44
+ }
45
+ /** Whether the pair that signed is allowed anywhere near this bucket name. */
46
+ function mayTouch(credential, bucket) {
47
+ const only = credential.buckets;
48
+ return only === undefined || only.includes(bucket);
49
+ }
50
+ /** What `ListBuckets` says: what the gateway can name, narrowed to what this pair may touch. */
51
+ async function bucketsFor(options, credential) {
52
+ const only = credential.buckets;
53
+ if (options.bucketNames === undefined)
54
+ return only ?? [];
55
+ const named = await options.bucketNames();
56
+ return only === undefined ? named : named.filter((name) => only.includes(name));
57
+ }
58
+ function objectHeaders(object) {
59
+ return {
60
+ "content-type": "application/octet-stream",
61
+ "last-modified": new Date(object.entry.updatedAt).toUTCString(),
62
+ etag: object.etag,
63
+ "accept-ranges": "none",
64
+ };
65
+ }
66
+ export async function handle(req, res, options) {
67
+ const url = req.url ?? "/";
68
+ const at = url.indexOf("?");
69
+ const pathname = at < 0 ? url : url.slice(0, at);
70
+ const query = new URLSearchParams(at < 0 ? "" : url.slice(at + 1));
71
+ const method = (req.method ?? "GET").toUpperCase();
72
+ const verdict = verifyAgainst({ method, url, headers: req.headers }, options.credentials, options.now?.() ?? Date.now());
73
+ if (!verdict.ok) {
74
+ fail(res, 403, verdict.code, verdict.message, pathname);
75
+ return;
76
+ }
77
+ const credential = verdict.credential;
78
+ const { bucket, key } = splitPath(pathname);
79
+ if (pathname === "/" && (method === "GET" || method === "HEAD")) {
80
+ const body = listBucketsXml(await bucketsFor(options, credential), new Date(0).toISOString());
81
+ res.writeHead(200, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
82
+ res.end(method === "HEAD" ? undefined : body);
83
+ return;
84
+ }
85
+ if (!mayTouch(credential, bucket)) {
86
+ fail(res, 403, "AccessDenied", NOT_YOURS, pathname);
87
+ return;
88
+ }
89
+ const source = await options.bucketOf(bucket);
90
+ if (source === null) {
91
+ fail(res, 404, "NoSuchBucket", `No bucket named ${bucket} is served here.`, pathname);
92
+ return;
93
+ }
94
+ // ⛔ MEASURED, NOT GUESSED: rclone's first act when copying a file is to create the bucket, and a
95
+ // refusal here ends the copy before the upload is ever attempted. The bucket exists, so the
96
+ // honest answer to "make it" is that it is made.
97
+ if (key === "" && method === "PUT") {
98
+ res.writeHead(200, { "content-length": "0" });
99
+ res.end();
100
+ return;
101
+ }
102
+ const entries = await source.entries();
103
+ if (key === "" && (method === "GET" || method === "HEAD")) {
104
+ const objects = objectsOf(entries);
105
+ const listing = listObjects(objects, folderPrefixesOf(entries), {
106
+ prefix: query.get("prefix") ?? "",
107
+ delimiter: query.get("delimiter") ?? "",
108
+ maxKeys: Number(query.get("max-keys") ?? MAX_KEYS_LIMIT) || MAX_KEYS_LIMIT,
109
+ after: query.get("continuation-token") ?? query.get("start-after") ?? query.get("marker"),
110
+ });
111
+ const body = listObjectsXml({
112
+ bucket,
113
+ prefix: query.get("prefix") ?? "",
114
+ delimiter: query.get("delimiter") ?? "",
115
+ maxKeys: Number(query.get("max-keys") ?? MAX_KEYS_LIMIT) || MAX_KEYS_LIMIT,
116
+ v2: query.get("list-type") === "2",
117
+ contents: listing.contents,
118
+ commonPrefixes: listing.commonPrefixes,
119
+ truncated: listing.truncated,
120
+ next: listing.next,
121
+ encodingType: query.get("encoding-type"),
122
+ });
123
+ res.writeHead(200, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
124
+ res.end(method === "HEAD" ? undefined : body);
125
+ options.log?.(`${method} list prefix=${query.get("prefix") ?? ""} → ${listing.contents.length} keys`);
126
+ return;
127
+ }
128
+ if (method === "HEAD" || method === "GET") {
129
+ const object = objectsOf(entries).find((o) => o.key === key);
130
+ if (object === undefined) {
131
+ fail(res, 404, "NoSuchKey", "This account's file list has no such file.", pathname);
132
+ return;
133
+ }
134
+ if (method === "HEAD") {
135
+ res.writeHead(200, { ...objectHeaders(object), "content-length": String(object.size) });
136
+ res.end();
137
+ options.log?.(`HEAD ${key}`);
138
+ return;
139
+ }
140
+ if (object.entry.dekWrapped === undefined) {
141
+ fail(res, 500, "InternalError", "That entry has no key in the file list.", pathname);
142
+ return;
143
+ }
144
+ const sink = responseSink(res, { headers: objectHeaders(object) });
145
+ try {
146
+ await source.fetch(object, sink);
147
+ options.log?.(`GET ${key} → ${object.size} bytes`);
148
+ }
149
+ catch (error) {
150
+ await sink.abandon();
151
+ if (!res.headersSent) {
152
+ fail(res, 502, "InternalError", error instanceof Error ? error.message : String(error), pathname);
153
+ }
154
+ options.log?.(`GET ${key} → failed`);
155
+ }
156
+ return;
157
+ }
158
+ const writer = source.write;
159
+ // ⛔ WHETHER A TAKEN KEY IS A CONFLICT IS NOT DECIDED HERE.
160
+ // It used to be, on the strength of the NAME alone, and both upload paths carried their own
161
+ // copy of that check. The question is now about CONTENT — is the file arriving the file
162
+ // already there — and it cannot be answered until the bytes have arrived, so it is answered
163
+ // once, by the writer, at the point both paths meet. What reaches this layer is the verdict:
164
+ // a writer that returns normally means the key now holds these bytes (whether it had to send
165
+ // them or they were already there), and one that throws a conflict means something else is at
166
+ // that key. ⭐ The status matters: 409 is a request the drive declined, 500 is a fault of ours.
167
+ const refuseConflict = (error) => {
168
+ fail(res, 409, "InvalidRequest", error instanceof Error ? error.message : String(error), pathname);
169
+ };
170
+ if (isMultipartRequest(method, query) && key !== "") {
171
+ if (writer === undefined) {
172
+ fail(res, 501, "NotImplemented", options.readOnlyBecause ?? readOnlyOnThisMachine(), pathname);
173
+ return;
174
+ }
175
+ const handled = await handleMultipart({
176
+ req,
177
+ res,
178
+ bucket,
179
+ key,
180
+ method,
181
+ query,
182
+ writer,
183
+ payloadHash: /^[0-9a-f]{64}$/.test(verdict.payloadHash) ? verdict.payloadHash : null,
184
+ fail: (status, code, message) => fail(res, status, code, message, pathname),
185
+ ...(options.log === undefined ? {} : { log: options.log }),
186
+ });
187
+ if (handled)
188
+ return;
189
+ }
190
+ if (method === "PUT" && key !== "") {
191
+ if (writer === undefined) {
192
+ fail(res, 501, "NotImplemented", options.readOnlyBecause ?? readOnlyOnThisMachine(), pathname);
193
+ return;
194
+ }
195
+ const declared = headerOf(req, "x-amz-content-sha256");
196
+ if (declared === STREAMING_PAYLOAD || declared === STREAMING_PAYLOAD_TRAILER) {
197
+ fail(res, 501, "NotImplemented", "This gateway does not read chunk-signed uploads yet. Tell the client to send the body " +
198
+ "unsigned (the AWS CLI calls this --no-sign-payload on http endpoints; rclone already " +
199
+ "does it).", pathname);
200
+ return;
201
+ }
202
+ const length = Number(headerOf(req, "content-length") ?? "");
203
+ if (!Number.isInteger(length) || length < 0) {
204
+ fail(res, 411, "MissingContentLength", "This gateway needs to know the size before it starts.", pathname);
205
+ return;
206
+ }
207
+ try {
208
+ await writer.put(key, req, length);
209
+ }
210
+ catch (error) {
211
+ if (isKeyConflict(error)) {
212
+ refuseConflict(error);
213
+ return;
214
+ }
215
+ fail(res, 500, "InternalError", error instanceof Error ? error.message : String(error), pathname);
216
+ return;
217
+ }
218
+ res.writeHead(200, { "content-length": "0" });
219
+ res.end();
220
+ options.log?.(`PUT ${key} → ${length} bytes`);
221
+ return;
222
+ }
223
+ if (method === "DELETE" && key !== "") {
224
+ if (writer === undefined) {
225
+ fail(res, 501, "NotImplemented", options.readOnlyBecause ?? readOnlyOnThisMachine(), pathname);
226
+ return;
227
+ }
228
+ const object = objectsOf(entries).find((o) => o.key === key);
229
+ if (object === undefined) {
230
+ // S3 answers 204 for a key that is not there, and clients rely on it: a sync that deletes
231
+ // the same key twice must not fail the second time.
232
+ res.writeHead(204);
233
+ res.end();
234
+ return;
235
+ }
236
+ try {
237
+ await writer.trash(object);
238
+ }
239
+ catch (error) {
240
+ fail(res, 500, "InternalError", error instanceof Error ? error.message : String(error), pathname);
241
+ return;
242
+ }
243
+ res.writeHead(204);
244
+ res.end();
245
+ options.log?.(`DELETE ${key} → trash`);
246
+ return;
247
+ }
248
+ fail(res, 501, "NotImplemented", `This gateway does not answer ${method} on that address.`, pathname);
249
+ }
@@ -1,86 +1,9 @@
1
1
  import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
- import type { Readable } from "node:stream";
3
- import type { PlaintextSink } from "../download-sink.ts";
4
- import type { ManifestEntry } from "../shared/lib/drive/manifest-codec.ts";
5
- import { type DriveObject } from "./listing.ts";
6
- import { type GatewayCredential } from "./sigv4.ts";
2
+ import type { GatewayOptions } from "./contract.ts";
3
+ import type { GatewayCredential } from "./sigv4.ts";
4
+ export type { DriveSource, DriveWriter, GatewayOptions } from "./contract.ts";
7
5
  /** Where the drive is served. Loopback, always — see the note above. */
8
6
  export declare const BIND_ADDRESS = "127.0.0.1";
9
- export interface DriveSource {
10
- /** The account's live file list. Called per request; the caller decides what to cache. */
11
- entries(): Promise<readonly ManifestEntry[]>;
12
- /**
13
- * Fetch, decrypt and deliver one file into the sink.
14
- *
15
- * ⛔ INJECTED RATHER THAN IMPORTED so this server can be driven by a real S3 client in a test
16
- * without an account, a network and somebody's credits. A gateway whose only test is an
17
- * end-to-end one is a gateway whose refusals are never tested at all.
18
- */
19
- fetch(object: DriveObject, sink: PlaintextSink): Promise<void>;
20
- /**
21
- * How to change the drive, when this machine has agreed to spending.
22
- *
23
- * ⛔ ABSENT MEANS READ ONLY, AND THAT IS A REFUSAL RATHER THAN A GAP. Uploading spends credits,
24
- * which is one of the three things this tool asks a person about once per machine, and a
25
- * gateway cannot ask: its stdin is not a terminal and the caller is a program. So the
26
- * agreement has to exist beforehand, and where it does not, every write says so.
27
- */
28
- readonly write?: DriveWriter;
29
- }
30
- export interface DriveWriter {
31
- /** Store `body` at this key. `size` is the byte count the client declared. */
32
- put(key: string, body: Readable, size: number): Promise<void>;
33
- /** Send one file to the trash, where it stays recoverable for thirty days. */
34
- trash(object: DriveObject): Promise<void>;
35
- /**
36
- * Staging for uploads that arrive in pieces. Absent means this gateway refuses them.
37
- *
38
- * ⚠ Separate from `put` because the pieces have to land somewhere before they are one file, and
39
- * where that is belongs to whoever is running this rather than to the protocol.
40
- */
41
- readonly multipart?: {
42
- begin(key: string): Promise<string>;
43
- part(uploadId: string, partNumber: number, body: Readable, size: number, expectedSha256: string | null): Promise<string>;
44
- complete(uploadId: string): Promise<string>;
45
- abort(uploadId: string): Promise<void>;
46
- };
47
- }
48
- export interface GatewayOptions {
49
- /**
50
- * Every pair that may sign a request here, each optionally held to named buckets.
51
- *
52
- * ⛔ A LIST RATHER THAN ONE PAIR BECAUSE A BUCKET IS AN ACCOUNT. `nmts s3` makes one pair for one
53
- * drive; a business serving many of its users' accounts hands each of them a pair of their
54
- * own, and the restriction on the pair is what stops one customer reading another's bucket.
55
- */
56
- readonly credentials: readonly GatewayCredential[];
57
- /**
58
- * Which drive answers to this bucket name, or null when none does.
59
- *
60
- * ⛔ THE GATEWAY DOES NOT KNOW WHAT A BUCKET IS. It was one name and one drive for as long as the
61
- * only caller was the command-line tool; asked by a business's server it is a lookup that
62
- * server does, and one it may do differently per name. What must not change is that a name
63
- * this resolver refuses looks exactly like a name the caller may not touch (see below).
64
- */
65
- readonly bucketOf: (name: string) => DriveSource | null | Promise<DriveSource | null>;
66
- /**
67
- * The names `ListBuckets` answers with, before the signing pair's own restriction is applied.
68
- *
69
- * ⚠ ABSENT IS A REAL ANSWER RATHER THAN A GAP. A gateway in front of a business's own lookup
70
- * cannot enumerate its customers, so what it can honestly name is what the presented pair is
71
- * held to — and an unrestricted pair on such a gateway is told nothing, which is true.
72
- */
73
- readonly bucketNames?: () => readonly string[] | Promise<readonly string[]>;
74
- /** Called with one line whenever a request is answered, so a person can watch what a tool does. */
75
- readonly log?: (line: string) => void;
76
- /** Passed in so a test can hold the clock still. */
77
- readonly now?: () => number;
78
- /**
79
- * The sentence a write gets from a read-only drive. `nmts s3` says what a person runs on this
80
- * machine to allow spending; a gateway somebody else runs has a different way in, and says its own.
81
- */
82
- readonly readOnlyBecause?: string | undefined;
83
- }
84
7
  /** A random pair, made fresh every time the gateway starts and stored nowhere. */
85
8
  export declare function newCredential(): GatewayCredential;
86
9
  /** A plain Node request handler, so this can be mounted in somebody else's server. */
package/dist/s3/server.js CHANGED
@@ -6,29 +6,14 @@
6
6
  // account -- and the key it checks was printed on somebody's terminal. This is the same call the
7
7
  // rest of the system made on 2026-08-20 when every container port was pulled back to loopback.
8
8
  //
9
- // WHAT IS NOT ANSWERED IS REFUSED, LOUDLY. An S3 client that asks for something this gateway does
10
- // not do gets 501 and a sentence naming what it does do. The alternative -- answering an empty
11
- // listing, or a 200 with nothing behind it -- is how a backup tool reports success over a backup
12
- // that never happened.
9
+ // WHAT EACH REQUEST IS ANSWERED WITH IS IN `routes.ts`, and what a caller has to hand this in
10
+ // `contract.ts`. What is here is the socket and the pair: the two things that decide who can
11
+ // reach the drive at all.
13
12
  import { createServer } from "node:http";
14
13
  import { randomBytes } from "node:crypto";
15
- import { listObjects, objectsOf, folderPrefixesOf, MAX_KEYS_LIMIT } from "./listing.js";
16
- import { handleMultipart, isMultipartRequest } from "./multipart.js";
17
- import { responseSink } from "./response-sink.js";
18
- import { isKeyConflict } from "./same-file.js";
19
- import { STREAMING_PAYLOAD, STREAMING_PAYLOAD_TRAILER, verifyAgainst, } from "./sigv4.js";
20
- import { errorXml, listBucketsXml, listObjectsXml } from "./xml.js";
14
+ import { fail, handle } from "./routes.js";
21
15
  /** Where the drive is served. Loopback, always — see the note above. */
22
16
  export const BIND_ADDRESS = "127.0.0.1";
23
- /**
24
- * What a caller signed with a pair it may not use here.
25
- *
26
- * ⛔ THE SAME ANSWER WHETHER OR NOT THE BUCKET EXISTS, which is why the restriction is checked
27
- * before the resolver is asked. Answering `NoSuchBucket` for a name the caller may not touch
28
- * would turn this gateway into a way of asking "does this business have a customer called…",
29
- * one guess at a time.
30
- */
31
- const NOT_YOURS = "That access key may not use that bucket.";
32
17
  /** A random pair, made fresh every time the gateway starts and stored nowhere. */
33
18
  export function newCredential() {
34
19
  const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
@@ -38,234 +23,6 @@ export function newCredential() {
38
23
  id += letters[byte % letters.length] ?? "A";
39
24
  return { accessKeyId: id.slice(0, 20), secretAccessKey: randomBytes(30).toString("base64url") };
40
25
  }
41
- function fail(res, status, code, message, resource) {
42
- const body = errorXml(code, message, resource);
43
- res.writeHead(status, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
44
- res.end(body);
45
- }
46
- /** `/drive/photos/a.jpg` → bucket `drive`, key `photos/a.jpg`. */
47
- function splitPath(pathname) {
48
- const trimmed = pathname.replace(/^\//, "");
49
- const at = trimmed.indexOf("/");
50
- if (at < 0)
51
- return { bucket: decodeURIComponent(trimmed), key: "" };
52
- return { bucket: decodeURIComponent(trimmed.slice(0, at)), key: decodeURIComponent(trimmed.slice(at + 1)) };
53
- }
54
- function headerOf(req, name) {
55
- const raw = req.headers[name];
56
- return Array.isArray(raw) ? raw.join(",") : raw;
57
- }
58
- /** The one sentence a write gets from `nmts s3` when this machine has not agreed to spending. */
59
- function readOnlyOnThisMachine() {
60
- return ("This gateway is read only. Uploading spends credits, and this machine has not agreed to " +
61
- "spending — `nmts consent grant spend`, run by the person whose account this is, is what " +
62
- "changes that. Nothing was written.");
63
- }
64
- /** Whether the pair that signed is allowed anywhere near this bucket name. */
65
- function mayTouch(credential, bucket) {
66
- const only = credential.buckets;
67
- return only === undefined || only.includes(bucket);
68
- }
69
- /** What `ListBuckets` says: what the gateway can name, narrowed to what this pair may touch. */
70
- async function bucketsFor(options, credential) {
71
- const only = credential.buckets;
72
- if (options.bucketNames === undefined)
73
- return only ?? [];
74
- const named = await options.bucketNames();
75
- return only === undefined ? named : named.filter((name) => only.includes(name));
76
- }
77
- function objectHeaders(object) {
78
- return {
79
- "content-type": "application/octet-stream",
80
- "last-modified": new Date(object.entry.updatedAt).toUTCString(),
81
- etag: object.etag,
82
- "accept-ranges": "none",
83
- };
84
- }
85
- async function handle(req, res, options) {
86
- const url = req.url ?? "/";
87
- const at = url.indexOf("?");
88
- const pathname = at < 0 ? url : url.slice(0, at);
89
- const query = new URLSearchParams(at < 0 ? "" : url.slice(at + 1));
90
- const method = (req.method ?? "GET").toUpperCase();
91
- const verdict = verifyAgainst({ method, url, headers: req.headers }, options.credentials, options.now?.() ?? Date.now());
92
- if (!verdict.ok) {
93
- fail(res, 403, verdict.code, verdict.message, pathname);
94
- return;
95
- }
96
- const credential = verdict.credential;
97
- const { bucket, key } = splitPath(pathname);
98
- if (pathname === "/" && (method === "GET" || method === "HEAD")) {
99
- const body = listBucketsXml(await bucketsFor(options, credential), new Date(0).toISOString());
100
- res.writeHead(200, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
101
- res.end(method === "HEAD" ? undefined : body);
102
- return;
103
- }
104
- if (!mayTouch(credential, bucket)) {
105
- fail(res, 403, "AccessDenied", NOT_YOURS, pathname);
106
- return;
107
- }
108
- const source = await options.bucketOf(bucket);
109
- if (source === null) {
110
- fail(res, 404, "NoSuchBucket", `No bucket named ${bucket} is served here.`, pathname);
111
- return;
112
- }
113
- // ⛔ MEASURED, NOT GUESSED: rclone's first act when copying a file is to create the bucket, and a
114
- // refusal here ends the copy before the upload is ever attempted. The bucket exists, so the
115
- // honest answer to "make it" is that it is made.
116
- if (key === "" && method === "PUT") {
117
- res.writeHead(200, { "content-length": "0" });
118
- res.end();
119
- return;
120
- }
121
- const entries = await source.entries();
122
- if (key === "" && (method === "GET" || method === "HEAD")) {
123
- const objects = objectsOf(entries);
124
- const listing = listObjects(objects, folderPrefixesOf(entries), {
125
- prefix: query.get("prefix") ?? "",
126
- delimiter: query.get("delimiter") ?? "",
127
- maxKeys: Number(query.get("max-keys") ?? MAX_KEYS_LIMIT) || MAX_KEYS_LIMIT,
128
- after: query.get("continuation-token") ?? query.get("start-after") ?? query.get("marker"),
129
- });
130
- const body = listObjectsXml({
131
- bucket,
132
- prefix: query.get("prefix") ?? "",
133
- delimiter: query.get("delimiter") ?? "",
134
- maxKeys: Number(query.get("max-keys") ?? MAX_KEYS_LIMIT) || MAX_KEYS_LIMIT,
135
- v2: query.get("list-type") === "2",
136
- contents: listing.contents,
137
- commonPrefixes: listing.commonPrefixes,
138
- truncated: listing.truncated,
139
- next: listing.next,
140
- encodingType: query.get("encoding-type"),
141
- });
142
- res.writeHead(200, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
143
- res.end(method === "HEAD" ? undefined : body);
144
- options.log?.(`${method} list prefix=${query.get("prefix") ?? ""} → ${listing.contents.length} keys`);
145
- return;
146
- }
147
- if (method === "HEAD" || method === "GET") {
148
- const object = objectsOf(entries).find((o) => o.key === key);
149
- if (object === undefined) {
150
- fail(res, 404, "NoSuchKey", "This account's file list has no such file.", pathname);
151
- return;
152
- }
153
- if (method === "HEAD") {
154
- res.writeHead(200, { ...objectHeaders(object), "content-length": String(object.size) });
155
- res.end();
156
- options.log?.(`HEAD ${key}`);
157
- return;
158
- }
159
- if (object.entry.dekWrapped === undefined) {
160
- fail(res, 500, "InternalError", "That entry has no key in the file list.", pathname);
161
- return;
162
- }
163
- const sink = responseSink(res, { headers: objectHeaders(object) });
164
- try {
165
- await source.fetch(object, sink);
166
- options.log?.(`GET ${key} → ${object.size} bytes`);
167
- }
168
- catch (error) {
169
- await sink.abandon();
170
- if (!res.headersSent) {
171
- fail(res, 502, "InternalError", error instanceof Error ? error.message : String(error), pathname);
172
- }
173
- options.log?.(`GET ${key} → failed`);
174
- }
175
- return;
176
- }
177
- const writer = source.write;
178
- // ⛔ WHETHER A TAKEN KEY IS A CONFLICT IS NOT DECIDED HERE.
179
- // It used to be, on the strength of the NAME alone, and both upload paths carried their own
180
- // copy of that check. The question is now about CONTENT — is the file arriving the file
181
- // already there — and it cannot be answered until the bytes have arrived, so it is answered
182
- // once, by the writer, at the point both paths meet. What reaches this layer is the verdict:
183
- // a writer that returns normally means the key now holds these bytes (whether it had to send
184
- // them or they were already there), and one that throws a conflict means something else is at
185
- // that key. ⭐ The status matters: 409 is a request the drive declined, 500 is a fault of ours.
186
- const refuseConflict = (error) => {
187
- fail(res, 409, "InvalidRequest", error instanceof Error ? error.message : String(error), pathname);
188
- };
189
- if (isMultipartRequest(method, query) && key !== "") {
190
- if (writer === undefined) {
191
- fail(res, 501, "NotImplemented", options.readOnlyBecause ?? readOnlyOnThisMachine(), pathname);
192
- return;
193
- }
194
- const handled = await handleMultipart({
195
- req,
196
- res,
197
- bucket,
198
- key,
199
- method,
200
- query,
201
- writer,
202
- payloadHash: /^[0-9a-f]{64}$/.test(verdict.payloadHash) ? verdict.payloadHash : null,
203
- fail: (status, code, message) => fail(res, status, code, message, pathname),
204
- ...(options.log === undefined ? {} : { log: options.log }),
205
- });
206
- if (handled)
207
- return;
208
- }
209
- if (method === "PUT" && key !== "") {
210
- if (writer === undefined) {
211
- fail(res, 501, "NotImplemented", options.readOnlyBecause ?? readOnlyOnThisMachine(), pathname);
212
- return;
213
- }
214
- const declared = headerOf(req, "x-amz-content-sha256");
215
- if (declared === STREAMING_PAYLOAD || declared === STREAMING_PAYLOAD_TRAILER) {
216
- fail(res, 501, "NotImplemented", "This gateway does not read chunk-signed uploads yet. Tell the client to send the body " +
217
- "unsigned (the AWS CLI calls this --no-sign-payload on http endpoints; rclone already " +
218
- "does it).", pathname);
219
- return;
220
- }
221
- const length = Number(headerOf(req, "content-length") ?? "");
222
- if (!Number.isInteger(length) || length < 0) {
223
- fail(res, 411, "MissingContentLength", "This gateway needs to know the size before it starts.", pathname);
224
- return;
225
- }
226
- try {
227
- await writer.put(key, req, length);
228
- }
229
- catch (error) {
230
- if (isKeyConflict(error)) {
231
- refuseConflict(error);
232
- return;
233
- }
234
- fail(res, 500, "InternalError", error instanceof Error ? error.message : String(error), pathname);
235
- return;
236
- }
237
- res.writeHead(200, { "content-length": "0" });
238
- res.end();
239
- options.log?.(`PUT ${key} → ${length} bytes`);
240
- return;
241
- }
242
- if (method === "DELETE" && key !== "") {
243
- if (writer === undefined) {
244
- fail(res, 501, "NotImplemented", options.readOnlyBecause ?? readOnlyOnThisMachine(), pathname);
245
- return;
246
- }
247
- const object = objectsOf(entries).find((o) => o.key === key);
248
- if (object === undefined) {
249
- // S3 answers 204 for a key that is not there, and clients rely on it: a sync that deletes
250
- // the same key twice must not fail the second time.
251
- res.writeHead(204);
252
- res.end();
253
- return;
254
- }
255
- try {
256
- await writer.trash(object);
257
- }
258
- catch (error) {
259
- fail(res, 500, "InternalError", error instanceof Error ? error.message : String(error), pathname);
260
- return;
261
- }
262
- res.writeHead(204);
263
- res.end();
264
- options.log?.(`DELETE ${key} → trash`);
265
- return;
266
- }
267
- fail(res, 501, "NotImplemented", `This gateway does not answer ${method} on that address.`, pathname);
268
- }
269
26
  /**
270
27
  * The gateway as a handler, which is the form that listens to nothing.
271
28
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needmoretruth/nmts-cli",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
4
4
  "description": "Command-line and MCP access to NMTS (NeedMoreTruthStorage, https://nmts.me): open-source, end-to-end encrypted storage on the Walrus network. No fee to NMTS; you pay the network from your own wallet. For people and for their agents.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {