sliftutils 1.7.9 → 1.7.11

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.
Files changed (92) hide show
  1. package/bin/storageserver.js +4 -0
  2. package/{teststorage → examplestorage}/browser.tsx +18 -22
  3. package/{teststorage/server.ts → examplestorage/exampleserver.ts} +4 -6
  4. package/index.d.ts +770 -84
  5. package/misc/dist/environment.ts.cache +3 -3
  6. package/misc/dist/getSecret.ts.cache +139 -0
  7. package/misc/dist/strings.ts.cache +13 -0
  8. package/misc/dist/zip.ts.cache +9 -0
  9. package/misc/getSecret.ts +38 -16
  10. package/misc/https/cloudflareHelpers.d.ts +19 -0
  11. package/misc/https/cloudflareHelpers.ts +69 -0
  12. package/misc/https/dist/certs.ts.cache +115 -179
  13. package/misc/https/dist/cloudflareHelpers.ts.cache +68 -0
  14. package/misc/https/dist/dns.ts.cache +112 -100
  15. package/misc/https/dist/hostServer.ts.cache +130 -0
  16. package/misc/https/dist/httpsCerts.ts.cache +24 -5
  17. package/misc/https/dist/persistentLocalStorage.ts.cache +3 -2
  18. package/misc/https/dns.d.ts +41 -8
  19. package/misc/https/dns.ts +104 -106
  20. package/misc/https/hostServer.d.ts +0 -3
  21. package/misc/https/hostServer.ts +12 -8
  22. package/package.json +3 -2
  23. package/render-utils/dist/observer.tsx.cache +3 -2
  24. package/spec.txt +0 -55
  25. package/storage/ArchivesDisk.d.ts +67 -0
  26. package/storage/ArchivesDisk.ts +369 -0
  27. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +1242 -0
  28. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +513 -0
  29. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +352 -0
  30. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +306 -0
  31. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +435 -0
  32. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +58 -0
  33. package/storage/BulkDatabase2/dist/blockCache.ts.cache +179 -0
  34. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +149 -0
  35. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +101 -0
  36. package/storage/BulkDatabase2/dist/streamLog.ts.cache +227 -0
  37. package/storage/BulkDatabase2/dist/syncClient.ts.cache +104 -0
  38. package/storage/IArchives.d.ts +127 -1
  39. package/storage/IArchives.ts +201 -1
  40. package/storage/backblaze.d.ts +29 -2
  41. package/storage/backblaze.ts +214 -78
  42. package/storage/dist/ArchivesDisk.ts.cache +373 -0
  43. package/storage/dist/FileFolderAPI.tsx.cache +508 -65
  44. package/storage/dist/IArchives.ts.cache +44 -0
  45. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +8 -2
  46. package/storage/dist/JSONStorage.ts.cache +6 -2
  47. package/storage/dist/PendingManager.tsx.cache +1 -1
  48. package/storage/dist/TransactionStorage.ts.cache +231 -92
  49. package/storage/dist/backblaze.ts.cache +797 -0
  50. package/storage/dist/fileSystemPointer.ts.cache +36 -3
  51. package/storage/dist/remoteFileStorage.ts.cache +495 -0
  52. package/storage/remoteStorage/ArchivesRemote.d.ts +34 -18
  53. package/storage/remoteStorage/ArchivesRemote.ts +85 -65
  54. package/storage/remoteStorage/ArchivesUrl.d.ts +48 -0
  55. package/storage/remoteStorage/ArchivesUrl.ts +86 -0
  56. package/storage/remoteStorage/accessPage.tsx +111 -28
  57. package/storage/remoteStorage/blobStore.d.ts +128 -25
  58. package/storage/remoteStorage/blobStore.ts +757 -290
  59. package/storage/remoteStorage/cliArgs.d.ts +1 -0
  60. package/storage/remoteStorage/cliArgs.ts +9 -0
  61. package/storage/remoteStorage/createArchives.d.ts +85 -0
  62. package/storage/remoteStorage/createArchives.ts +612 -0
  63. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +197 -0
  64. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +85 -0
  65. package/storage/remoteStorage/dist/accessPage.tsx.cache +222 -0
  66. package/storage/remoteStorage/dist/blobStore.ts.cache +837 -0
  67. package/storage/remoteStorage/dist/cliArgs.ts.cache +16 -0
  68. package/storage/remoteStorage/dist/createArchives.ts.cache +610 -0
  69. package/storage/remoteStorage/dist/remoteConfig.ts.cache +206 -0
  70. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +223 -0
  71. package/storage/remoteStorage/dist/storageController.ts.cache +500 -0
  72. package/storage/remoteStorage/dist/storageServer.ts.cache +106 -0
  73. package/storage/remoteStorage/dist/storageServerCli.ts.cache +39 -0
  74. package/storage/remoteStorage/dist/storageServerState.ts.cache +518 -0
  75. package/storage/remoteStorage/grantAccess.js +4 -0
  76. package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
  77. package/storage/remoteStorage/grantAccessCli.ts +27 -0
  78. package/storage/remoteStorage/remoteConfig.d.ts +29 -0
  79. package/storage/remoteStorage/remoteConfig.ts +194 -0
  80. package/storage/remoteStorage/sourceWrapper.d.ts +41 -0
  81. package/storage/remoteStorage/sourceWrapper.ts +223 -0
  82. package/storage/remoteStorage/spec.md +31 -0
  83. package/storage/remoteStorage/storageController.d.ts +30 -27
  84. package/storage/remoteStorage/storageController.ts +297 -159
  85. package/storage/remoteStorage/storageServer.d.ts +6 -0
  86. package/storage/remoteStorage/storageServer.ts +88 -94
  87. package/storage/remoteStorage/storageServerCli.d.ts +1 -0
  88. package/storage/remoteStorage/storageServerCli.ts +38 -0
  89. package/storage/remoteStorage/storageServerState.d.ts +43 -0
  90. package/storage/remoteStorage/storageServerState.ts +540 -0
  91. package/testsite/server.ts +0 -2
  92. package/yarn.lock +11 -6
@@ -1,19 +1,29 @@
1
1
  module.allowclient = true;
2
2
 
3
3
  import { SocketFunction } from "socket-function/SocketFunction";
4
+ import { SocketFunctionHook } from "socket-function/SocketFunctionTypes";
4
5
  import { getNodeIdIP } from "socket-function/src/nodeCache";
5
- import { setHTTPResultHeaders } from "socket-function/src/callHTTPHandler";
6
+ import { setHTTPResultHeaders, getCurrentHTTPRequest } from "socket-function/src/callHTTPHandler";
7
+ import { performLocalCall } from "socket-function/src/callManager";
8
+ import { RequireController } from "socket-function/require/RequireController";
6
9
  import { timeInMinute } from "socket-function/src/misc";
7
10
  import { getCommonName, getPublicIdentifier, getOwnMachineId, verify, verifyMachineIdForPublicKey } from "../../misc/https/certs";
8
- import { ArchiveFileInfo } from "../IArchives";
9
- import type { BlobStore, WriteConfig } from "./blobStore";
10
- import type { IStorage } from "../IStorage";
11
+ import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, IMMUTABLE_CACHE_TIME } from "../IArchives";
12
+ import { ROUTING_FILE } from "./remoteConfig";
13
+ import {
14
+ getStorageServerConfig, getTrust, getRequests, getLoadedBucket, writeBucketFile,
15
+ deleteBucketFile, assertWritesAllowed, assertMutable, LoadedBucket,
16
+ } from "./storageServerState";
11
17
 
12
18
  // The remote storage server's API. Authentication uses certs.ts machine identities: a client
13
19
  // proves it owns its machine key by signing a timestamped token (bound to this server, so tokens
14
20
  // can't be replayed elsewhere), and the server then trusts that connection as that machineId.
15
21
  // Access to an account is granted to specific machineIds, via a command line command run on the
16
22
  // storage machine (see storageServer.ts).
23
+ //
24
+ // There is no bucket-creation API: a bucket exists iff its routing config (ROUTING_FILE) exists,
25
+ // and writing that file creates or reconfigures the bucket (see storageServerState.ts). Reads of
26
+ // nonexistent buckets return undefined / empty, same as reads of nonexistent files.
17
27
 
18
28
  export const REMOTE_STORAGE_CLASS_GUID = "RemoteStorageController-b7e42a91";
19
29
  export const STORAGE_AUTH_PURPOSE = "remoteStorage-auth-1";
@@ -43,47 +53,20 @@ export type TrustRecord = {
43
53
  ip: string;
44
54
  time: number;
45
55
  };
46
- export type BucketConfig = {
47
- public?: boolean;
48
- fast?: boolean;
49
- writeDelay?: number;
50
- };
51
56
  export type AccessState = {
52
57
  machineId: string;
53
58
  ip: string;
54
59
  hasAccess: boolean;
55
- // Single ssh commands, runnable from anywhere, that run the admin CLI on the storage machine
56
- // (shown when the caller has no access). grantAccessCommand grants the caller's own request.
57
- listAccessCommand: string;
60
+ // A single ssh command, runnable from anywhere, that runs the grantAccess CLI on the storage
61
+ // machine to grant the caller's own pending request. Only set when the caller has a pending
62
+ // request (so an already-trusted caller has no need for it).
58
63
  grantAccessCommand?: string;
59
- // Only provided when the caller has access
60
- machines?: (AccessRequest & { trusted: boolean })[];
61
- };
62
-
63
- export type StorageServerState = {
64
- domain: string;
65
- port: number;
66
- rootDomain: string;
67
- // user@externalIp of the storage machine, for generated ssh commands
68
- sshTarget: string;
69
- // Absolute command that runs storageServer.ts on the storage machine (admin args appended)
70
- serverCommand: string;
71
- blobStore: BlobStore;
72
- trust: IStorage<TrustRecord>;
73
- requests: IStorage<AccessRequest[]>;
74
- buckets: IStorage<BucketConfig>;
64
+ // Only the machines that ALREADY have access. Pending requests are NEVER listed here — showing
65
+ // them would let a trusted user accidentally approve a random request. Callers see pending
66
+ // requests only by explicitly typing an IP into listRequestsForIP.
67
+ trustedMachines?: TrustRecord[];
75
68
  };
76
69
 
77
- let serverState: StorageServerState | undefined;
78
- export function setStorageServerState(state: StorageServerState) {
79
- serverState = state;
80
- }
81
- function getState(): StorageServerState {
82
- let state = serverState;
83
- if (!state) throw new Error(`Storage server is not initialized (this API only works on the storage server)`);
84
- return state;
85
- }
86
-
87
70
  // callerNodeId -> authenticated machineId. Connections are long-lived websockets, so a session
88
71
  // lasts until the connection drops (clients re-authenticate on reconnect).
89
72
  const sessions = new Map<string, string>();
@@ -107,6 +90,10 @@ function assertValidPath(path: string) {
107
90
  if (!path || path.startsWith("/") || path.endsWith("/") || path.includes("//") || path.includes("\\") || path.includes("\x00")) {
108
91
  throw new Error(`Invalid path ${JSON.stringify(path.slice(0, 200))}, paths cannot be empty, start or end with /, or contain //, backslashes, or null characters`);
109
92
  }
93
+ // Paths are one-to-one with files on disk, so . and .. segments would escape the store folder
94
+ if (path.split("/").some(part => part === "." || part === "..")) {
95
+ throw new Error(`Invalid path ${JSON.stringify(path.slice(0, 200))}, paths cannot contain . or .. segments`);
96
+ }
110
97
  }
111
98
 
112
99
  function getCallerMachineId(): string {
@@ -121,8 +108,7 @@ function getCallerIP(): string {
121
108
  return getNodeIdIP(SocketFunction.getCaller().nodeId);
122
109
  }
123
110
  function isAdmin(machineId: string): boolean {
124
- let state = getState();
125
- return machineId === getOwnMachineId(state.rootDomain);
111
+ return machineId === getOwnMachineId(getStorageServerConfig().rootDomain);
126
112
  }
127
113
  function requireAdmin(): string {
128
114
  let machineId = getCallerMachineId();
@@ -133,46 +119,38 @@ function requireAdmin(): string {
133
119
  }
134
120
  async function requireAccess(account: string): Promise<string> {
135
121
  assertValidName(account, "account");
136
- let state = getState();
137
122
  let machineId = getCallerMachineId();
138
123
  if (isAdmin(machineId)) return machineId;
139
- let trusted = await state.trust.get(`${account}|${machineId}`);
124
+ let trust = await getTrust();
125
+ let trusted = await trust.get(`${account}|${machineId}`);
140
126
  if (!trusted) {
141
- throw new Error(`${STORAGE_ACCESS_DENIED} Machine ${machineId} has no access to account ${JSON.stringify(account)}. Visit https://${state.domain}:${state.port}/${account} for access instructions.`);
127
+ let { domain, port } = getStorageServerConfig();
128
+ throw new Error(`${STORAGE_ACCESS_DENIED} Machine ${machineId} has no access to account ${JSON.stringify(account)}. Visit https://${domain}:${port}/${account} for access instructions.`);
142
129
  }
143
130
  return machineId;
144
131
  }
145
132
 
146
133
  // A single command, runnable from anywhere, that sshes into the storage machine and runs the
147
- // admin CLI there
148
- function getAdminCommand(args: string): string {
149
- let state = getState();
150
- return `ssh ${state.sshTarget} '${state.serverCommand} ${args}'`;
151
- }
152
- function getListAccessCommand(ip: string): string {
153
- return getAdminCommand(`--listAccess ${ip}`);
154
- }
134
+ // grantAccess CLI there
155
135
  function getGrantAccessCommand(requestId: string): string {
156
- return getAdminCommand(`--grantAccess ${requestId}`);
136
+ let { sshTarget, serverCommand } = getStorageServerConfig();
137
+ return `ssh ${sshTarget} '${serverCommand} --requestId ${requestId}'`;
157
138
  }
158
139
 
159
- async function getBucketWriteConfig(account: string, bucketName: string): Promise<WriteConfig> {
160
- let state = getState();
161
- let config = await state.buckets.get(`${account}/${bucketName}`);
162
- return { fast: config?.fast, writeDelay: config?.writeDelay };
163
- }
164
- function fileKey(account: string, bucketName: string, path: string): string {
140
+ async function getBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
165
141
  assertValidName(account, "account");
166
142
  assertValidName(bucketName, "bucket name");
167
- assertValidPath(path);
168
- return `${account}/${bucketName}/${path}`;
143
+ return await getLoadedBucket(account, bucketName);
169
144
  }
170
145
 
171
146
  class RemoteStorageControllerBase {
147
+ // Latency measurement (see SourceWrapper's pinging); no auth, it measures the transport
148
+ async ping(): Promise<void> { }
149
+
172
150
  // Proves the caller owns the machine key for the machineId in its certificate. The signature
173
151
  // must be fresh and bound to this server, so it cannot be replayed to (or from) other servers.
174
152
  async authenticate(token: AuthToken): Promise<{ machineId: string; ip: string }> {
175
- let state = getState();
153
+ let { domain, port } = getStorageServerConfig();
176
154
  let caller = SocketFunction.getCaller();
177
155
  if (Math.abs(Date.now() - token.time) > AUTH_TIME_WINDOW) {
178
156
  throw new Error(`Auth token time is too far from the server time (token ${token.time}, server ${Date.now()}, allowed drift ${AUTH_TIME_WINDOW}ms)`);
@@ -180,7 +158,7 @@ class RemoteStorageControllerBase {
180
158
  verify(token.certPem, token.signature, {
181
159
  purpose: STORAGE_AUTH_PURPOSE,
182
160
  time: token.time,
183
- server: `${state.domain}:${state.port}`,
161
+ server: `${domain}:${port}`,
184
162
  });
185
163
  let machineId = getCommonName(token.certPem).split(".")[0];
186
164
  if (!verifyMachineIdForPublicKey({ machineId, publicKey: getPublicIdentifier(token.certPem) })) {
@@ -199,10 +177,10 @@ class RemoteStorageControllerBase {
199
177
  // IP, so the storage machine's admin can list them with --listAccess <ip> and grant one.
200
178
  async requestAccess(account: string): Promise<{ machineId: string; ip: string; requestId: string; grantAccessCommand: string }> {
201
179
  assertValidName(account, "account");
202
- let state = getState();
203
180
  let machineId = getCallerMachineId();
204
181
  let ip = getCallerIP();
205
- let requests = await state.requests.get(ip) || [];
182
+ let requestsStorage = await getRequests();
183
+ let requests = await requestsStorage.get(ip) || [];
206
184
  let existing = requests.find(x => x.account === account && x.machineId === machineId);
207
185
  if (existing) {
208
186
  existing.time = Date.now();
@@ -217,63 +195,84 @@ class RemoteStorageControllerBase {
217
195
  requests.push(existing);
218
196
  }
219
197
  while (requests.length > MAX_REQUESTS_PER_IP) requests.shift();
220
- await state.requests.set(ip, requests);
198
+ await requestsStorage.set(ip, requests);
221
199
  return { machineId, ip, requestId: existing.requestId, grantAccessCommand: getGrantAccessCommand(existing.requestId) };
222
200
  }
223
201
 
224
202
  async getAccessState(account: string): Promise<AccessState> {
225
203
  assertValidName(account, "account");
226
- let state = getState();
227
204
  let machineId = getCallerMachineId();
228
205
  let ip = getCallerIP();
229
- let hasAccess = isAdmin(machineId) || !!await state.trust.get(`${account}|${machineId}`);
230
- let result: AccessState = { machineId, ip, hasAccess, listAccessCommand: getListAccessCommand(ip) };
206
+ let trust = await getTrust();
207
+ let hasAccess = isAdmin(machineId) || !!await trust.get(`${account}|${machineId}`);
208
+ let result: AccessState = { machineId, ip, hasAccess };
231
209
  if (!hasAccess) {
232
- let ownRequest = (await state.requests.get(ip) || []).find(x => x.account === account && x.machineId === machineId);
210
+ let requests = await getRequests();
211
+ let ownRequest = (await requests.get(ip) || []).find(x => x.account === account && x.machineId === machineId);
233
212
  if (ownRequest) {
234
213
  result.grantAccessCommand = getGrantAccessCommand(ownRequest.requestId);
235
214
  }
236
215
  return result;
237
216
  }
238
217
 
239
- let machines = new Map<string, AccessRequest & { trusted: boolean }>();
240
- for (let requestIp of await state.requests.getKeys()) {
241
- for (let request of await state.requests.get(requestIp) || []) {
242
- if (request.account !== account) continue;
243
- machines.set(request.machineId, { ...request, trusted: false });
244
- }
245
- }
246
- for (let key of await state.trust.getKeys()) {
218
+ let trustedMachines: TrustRecord[] = [];
219
+ for (let key of await trust.getKeys()) {
247
220
  if (!key.startsWith(`${account}|`)) continue;
248
- let record = await state.trust.get(key);
249
- if (!record) continue;
250
- let existing = machines.get(record.machineId);
251
- machines.set(record.machineId, {
252
- requestId: existing?.requestId || "",
253
- account,
254
- machineId: record.machineId,
255
- ip: record.ip,
256
- time: record.time,
257
- trusted: true,
258
- });
221
+ let record = await trust.get(key);
222
+ if (record) trustedMachines.push(record);
259
223
  }
260
- result.machines = Array.from(machines.values());
224
+ result.trustedMachines = trustedMachines;
261
225
  return result;
262
226
  }
263
227
 
228
+ // Callable by any machine that has access to `account`. Returns pending access requests for the
229
+ // account that come from EXACTLY `ip`. Callers must type in an IP explicitly — the server never
230
+ // volunteers a list of requesting IPs, so a trusted user can't accidentally approve a random
231
+ // request from a machine they didn't mean to trust.
232
+ async listRequestsForIP(account: string, ip: string): Promise<AccessRequest[]> {
233
+ let requests = await getRequests();
234
+ return (await requests.get(ip) || []).filter(x => x.account === account);
235
+ }
236
+
237
+ // Callable by any machine that has access to the request's account (or by the storage-machine
238
+ // admin). Grants the requested access; the caller must supply the specific requestId, which they
239
+ // only get by explicitly looking up requests for a specific IP.
240
+ async grantAccess(requestId: string): Promise<TrustRecord> {
241
+ // Must capture in the synchronous phase — SocketFunction.getCaller() only works before any await.
242
+ let callerMachineId = getCallerMachineId();
243
+ let trust = await getTrust();
244
+ let requests = await getRequests();
245
+ for (let ip of await requests.getKeys()) {
246
+ for (let request of await requests.get(ip) || []) {
247
+ if (request.requestId !== requestId) continue;
248
+ if (!isAdmin(callerMachineId) && !await trust.get(`${request.account}|${callerMachineId}`)) {
249
+ throw new Error(`${STORAGE_ACCESS_DENIED} Machine ${callerMachineId} has no access to account ${JSON.stringify(request.account)}`);
250
+ }
251
+ let record: TrustRecord = {
252
+ account: request.account,
253
+ machineId: request.machineId,
254
+ ip: request.ip,
255
+ time: Date.now(),
256
+ };
257
+ await trust.set(`${request.account}|${request.machineId}`, record);
258
+ return record;
259
+ }
260
+ }
261
+ throw new Error(`No access request found with id ${JSON.stringify(requestId)}. It may have already been granted or expired.`);
262
+ }
263
+
264
264
  // Admin (must be run from the storage machine itself, which shares the server's machineId).
265
265
  // Only returns requests for the given IP, so you cannot accidentally grant a request from an
266
266
  // IP you didn't explicitly type in.
267
267
  async adminListRequests(ip: string): Promise<AccessRequest[]> {
268
- requireAdmin();
269
- let state = getState();
270
- return await state.requests.get(ip) || [];
268
+ let requests = await getRequests();
269
+ return await requests.get(ip) || [];
271
270
  }
272
271
  async adminGrantAccess(requestId: string): Promise<TrustRecord> {
273
- requireAdmin();
274
- let state = getState();
275
- for (let ip of await state.requests.getKeys()) {
276
- for (let request of await state.requests.get(ip) || []) {
272
+ let trust = await getTrust();
273
+ let requests = await getRequests();
274
+ for (let ip of await requests.getKeys()) {
275
+ for (let request of await requests.get(ip) || []) {
277
276
  if (request.requestId !== requestId) continue;
278
277
  let record: TrustRecord = {
279
278
  account: request.account,
@@ -281,118 +280,257 @@ class RemoteStorageControllerBase {
281
280
  ip: request.ip,
282
281
  time: Date.now(),
283
282
  };
284
- await state.trust.set(`${request.account}|${request.machineId}`, record);
283
+ await trust.set(`${request.account}|${request.machineId}`, record);
285
284
  return record;
286
285
  }
287
286
  }
288
- throw new Error(`No access request found with id ${JSON.stringify(requestId)}. Run --listAccess <ip> to see request ids.`);
289
- }
290
-
291
- async ensureBucket(account: string, bucketName: string, config: BucketConfig): Promise<void> {
292
- await requireAccess(account);
293
- assertValidName(bucketName, "bucket name");
294
- let state = getState();
295
- let key = `${account}/${bucketName}`;
296
- let existing = await state.buckets.get(key);
297
- if (existing && JSON.stringify(existing) === JSON.stringify(config)) return;
298
- await state.buckets.set(key, config);
287
+ throw new Error(`No access request found with id ${JSON.stringify(requestId)}. It may have already been granted or expired.`);
299
288
  }
300
289
 
301
290
  async get(account: string, bucketName: string, path: string, range?: { start: number; end: number }): Promise<Buffer | undefined> {
302
- await requireAccess(account);
303
- return await getState().blobStore.get(fileKey(account, bucketName, path), range);
291
+ let result = await this.get2(account, bucketName, path, range);
292
+ return result && result.data || undefined;
304
293
  }
305
- async set(account: string, bucketName: string, path: string, data: Buffer): Promise<void> {
306
- await requireAccess(account);
307
- let writeConfig = await getBucketWriteConfig(account, bucketName);
308
- await getState().blobStore.set(fileKey(account, bucketName, path), Buffer.from(data), writeConfig);
294
+ async get2(account: string, bucketName: string, path: string, range?: { start: number; end: number }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
295
+ assertValidPath(path);
296
+ let bucket = await getBucket(account, bucketName);
297
+ if (!bucket) return undefined;
298
+ return await bucket.store.get2(path, { range });
299
+ }
300
+ async set(account: string, bucketName: string, path: string, data: Buffer, lastModified?: number): Promise<void> {
301
+ assertValidName(bucketName, "bucket name");
302
+ assertValidPath(path);
303
+ // Handles bucket creation (writes of ROUTING_FILE), reconfiguration, fast mode, and
304
+ // immutability — see storageServerState.ts
305
+ await writeBucketFile(account, bucketName, path, Buffer.from(data), { lastModified });
309
306
  }
310
307
  async del(account: string, bucketName: string, path: string): Promise<void> {
311
- await requireAccess(account);
312
- let writeConfig = await getBucketWriteConfig(account, bucketName);
313
- await getState().blobStore.del(fileKey(account, bucketName, path), writeConfig);
308
+ assertValidName(bucketName, "bucket name");
309
+ assertValidPath(path);
310
+ await deleteBucketFile(account, bucketName, path);
314
311
  }
315
312
  async getInfo(account: string, bucketName: string, path: string): Promise<{ writeTime: number; size: number } | undefined> {
316
- await requireAccess(account);
317
- return await getState().blobStore.getInfo(fileKey(account, bucketName, path));
313
+ assertValidPath(path);
314
+ let bucket = await getBucket(account, bucketName);
315
+ if (!bucket) return undefined;
316
+ return await bucket.store.getInfo(path);
318
317
  }
319
318
  async findInfo(account: string, bucketName: string, prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
320
- await requireAccess(account);
321
- assertValidName(bucketName, "bucket name");
322
- let bucketRoot = `${account}/${bucketName}/`;
323
- let infos = await getState().blobStore.findInfo(bucketRoot + prefix, config);
324
- return infos.map(info => ({ ...info, path: info.path.slice(bucketRoot.length) }));
319
+ let bucket = await getBucket(account, bucketName);
320
+ if (!bucket) return [];
321
+ return await bucket.store.findInfo(prefix, config);
322
+ }
323
+ // Fast (served from the store's BulkDatabase2 index, not a scan) — see IArchives.getChangesAfter
324
+ async getChangesAfter(account: string, bucketName: string, time: number): Promise<ArchiveFileInfo[]> {
325
+ let bucket = await getBucket(account, bucketName);
326
+ if (!bucket) return [];
327
+ if (!bucket.store.getChangesAfter) {
328
+ throw new Error(`Bucket ${account}/${bucketName} does not support getChangesAfter (rawDisk buckets have no index)`);
329
+ }
330
+ return await bucket.store.getChangesAfter(time);
331
+ }
332
+ async getArchivesConfig(account: string, bucketName: string): Promise<ArchivesConfig> {
333
+ let bucket = await getBucket(account, bucketName);
334
+ // Missing buckets say true, matching what they become once created (the default store type)
335
+ let progress = bucket?.store.getSyncProgress?.();
336
+ return {
337
+ supportsChangesAfter: !bucket || !!bucket.store.getChangesAfter,
338
+ remoteConfig: bucket?.routing,
339
+ index: progress?.index,
340
+ indexSources: progress?.sources,
341
+ readerDiskLimit: progress?.readerDiskLimit,
342
+ syncing: progress?.syncing,
343
+ };
344
+ }
345
+ /** Walks the whole index for exact totals (overall and per holding source) - more expensive
346
+ * than the maintained counters that getArchivesConfig returns, but immune to counter drift. */
347
+ async getIndexInfo(account: string, bucketName: string): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] } | undefined> {
348
+ let bucket = await getBucket(account, bucketName);
349
+ if (!bucket || !bucket.store.computeIndexTotals) return undefined;
350
+ return await bucket.store.computeIndexTotals();
351
+ }
352
+ async getSyncStatus(account: string, bucketName: string): Promise<ArchivesSyncStatus> {
353
+ let bucket = await getBucket(account, bucketName);
354
+ if (!bucket) return { allScansComplete: true, indexSize: 0, sources: [] };
355
+ if (!bucket.store.getSyncStatus) {
356
+ throw new Error(`Bucket ${account}/${bucketName} does not support getSyncStatus (rawDisk buckets have no synchronization)`);
357
+ }
358
+ return await bucket.store.getSyncStatus();
325
359
  }
326
360
 
327
361
  async startLargeFile(account: string, bucketName: string, path: string): Promise<string> {
328
- await requireAccess(account);
362
+ assertWritesAllowed();
329
363
  // Validates now, so the upload doesn't fail at the end
330
- fileKey(account, bucketName, path);
331
- let id = await getState().blobStore.startLargeUpload();
364
+ assertValidPath(path);
365
+ let bucket = await getBucket(account, bucketName);
366
+ if (!bucket) {
367
+ throw new Error(`Bucket ${account}/${bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
368
+ }
369
+ await assertMutable(bucket, path, Date.now());
370
+ let id = await bucket.store.startLargeUpload();
332
371
  largeUploadInfo.set(id, { account, bucketName, path });
333
372
  return id;
334
373
  }
335
374
  async uploadPart(uploadId: string, data: Buffer): Promise<void> {
375
+ assertWritesAllowed();
336
376
  let info = largeUploadInfo.get(uploadId);
337
377
  if (!info) throw new Error(`Unknown large upload ${uploadId}`);
338
- await requireAccess(info.account);
339
- await getState().blobStore.appendLargeUpload(uploadId, Buffer.from(data));
378
+ let bucket = await getBucket(info.account, info.bucketName);
379
+ if (!bucket) throw new Error(`Bucket ${info.account}/${info.bucketName} no longer exists`);
380
+ await bucket.store.appendLargeUpload(uploadId, Buffer.from(data));
340
381
  }
341
382
  async finishLargeFile(uploadId: string): Promise<void> {
383
+ assertWritesAllowed();
342
384
  let info = largeUploadInfo.get(uploadId);
343
385
  if (!info) throw new Error(`Unknown large upload ${uploadId}`);
344
- await requireAccess(info.account);
345
386
  largeUploadInfo.delete(uploadId);
346
- await getState().blobStore.finishLargeUpload(uploadId, fileKey(info.account, info.bucketName, info.path));
387
+ let bucket = await getBucket(info.account, info.bucketName);
388
+ if (!bucket) throw new Error(`Bucket ${info.account}/${info.bucketName} no longer exists`);
389
+ await bucket.store.finishLargeUpload(uploadId, info.path);
347
390
  }
348
391
  async cancelLargeFile(uploadId: string): Promise<void> {
349
392
  let info = largeUploadInfo.get(uploadId);
350
393
  if (!info) return;
351
- await requireAccess(info.account);
352
394
  largeUploadInfo.delete(uploadId);
353
- await getState().blobStore.cancelLargeUpload(uploadId);
395
+ let bucket = await getBucket(info.account, info.bucketName);
396
+ if (!bucket) return;
397
+ await bucket.store.cancelLargeUpload(uploadId);
354
398
  }
355
399
 
356
- // Serves files from public buckets over plain HTTP GET (see IArchives getURL). No
357
- // authentication, which is what public means (private buckets are API-access only).
358
- async getPublicFile(account: string, bucketName: string, path: string): Promise<Buffer> {
359
- let state = getState();
360
- let bucket = await state.buckets.get(`${account}/${bucketName}`);
361
- if (!bucket?.public) {
362
- throw new Error(`Bucket ${account}/${bucketName} is not public`);
400
+ // The server's single default HTTP route. /file/<account>/<bucketName>/<path> serves files from
401
+ // public buckets over plain GETs (see IArchives.getURL); every other path serves the access
402
+ // page (via RequireController.requireHTML the path is the account name, see accessPage.tsx).
403
+ async httpEntry(config?: { requireCalls?: string[]; cacheTime?: number }): Promise<Buffer> {
404
+ // Both are keyed by the current call and must be captured synchronously, before any await
405
+ let caller = SocketFunction.getCaller();
406
+ let request = getCurrentHTTPRequest();
407
+ let pathname = new URL(request?.url || "/", "https://localhost").pathname;
408
+ if (!pathname.startsWith("/file/")) {
409
+ return await performLocalCall({
410
+ caller,
411
+ call: { nodeId: caller.nodeId, classGuid: RequireController._classGuid, functionName: "requireHTML", args: [config] },
412
+ }) as Buffer;
413
+ }
414
+ let parts = pathname.split("/").filter(x => x).map(decodeURIComponent);
415
+ let account = parts[1];
416
+ let bucketName = parts[2];
417
+ let filePath = parts.slice(3).join("/");
418
+ if (!account || !bucketName || !filePath) {
419
+ return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
420
+ }
421
+ assertValidName(account, "account");
422
+ assertValidName(bucketName, "bucket name");
423
+ assertValidPath(filePath);
424
+ let bucket = await getLoadedBucket(account, bucketName);
425
+ if (!bucket) {
426
+ return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
427
+ }
428
+ if (!bucket.self?.public) {
429
+ throw new Error(`Bucket ${account}/${bucketName} is not public, so its files cannot be read over plain URLs`);
363
430
  }
364
- let data = await state.blobStore.get(fileKey(account, bucketName, path));
365
- if (!data) {
366
- throw new Error(`File not found: ${path} in ${account}/${bucketName}`);
431
+ // The index answers existence + write time + size without touching any data, so
432
+ // If-Modified-Since and range validation cost nothing
433
+ let info = await bucket.store.getInfo(filePath);
434
+ if (!info || !info.size) {
435
+ return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
367
436
  }
368
- let ext = path.split(".").pop() || "";
369
- return setHTTPResultHeaders(data, {
437
+ let ext = filePath.split(".").pop() || "";
438
+ let headers: { [header: string]: string } = {
370
439
  "Content-Type": CONTENT_TYPES[ext.toLowerCase()] || "application/octet-stream",
371
- });
440
+ "Last-Modified": new Date(info.writeTime).toUTCString(),
441
+ "Accept-Ranges": "bytes",
442
+ };
443
+ if (bucket.self?.immutable) {
444
+ headers["Cache-Control"] = `max-age=${IMMUTABLE_CACHE_TIME / 1000}`;
445
+ }
446
+ let ifModifiedSince = request?.headers["if-modified-since"];
447
+ if (typeof ifModifiedSince === "string") {
448
+ let since = new Date(ifModifiedSince).getTime();
449
+ // Last-Modified is served at 1 second resolution, so compare at that resolution
450
+ if (since && Math.floor(info.writeTime / 1000) * 1000 <= since) {
451
+ return setHTTPResultHeaders(Buffer.from(""), { ...headers, status: "304" });
452
+ }
453
+ }
454
+ let range: { start: number; end: number } | undefined;
455
+ let rangeHeader = request?.headers["range"];
456
+ if (typeof rangeHeader === "string") {
457
+ // Single-range form only (bytes=start-end / start- / -suffix); anything else serves the full file
458
+ let match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
459
+ if (match && (match[1] || match[2])) {
460
+ let start: number;
461
+ let endInclusive = info.size - 1;
462
+ if (!match[1]) {
463
+ start = Math.max(0, info.size - +match[2]);
464
+ } else {
465
+ start = +match[1];
466
+ if (match[2]) {
467
+ endInclusive = Math.min(+match[2], info.size - 1);
468
+ }
469
+ }
470
+ if (start >= info.size || start > endInclusive) {
471
+ return setHTTPResultHeaders(Buffer.from(""), { ...headers, "Content-Range": `bytes */${info.size}`, status: "416" });
472
+ }
473
+ range = { start, end: endInclusive + 1 };
474
+ }
475
+ }
476
+ let result = await bucket.store.get2(filePath, { range });
477
+ if (!result) {
478
+ return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
479
+ }
480
+ if (range) {
481
+ return setHTTPResultHeaders(result.data, { ...headers, "Content-Range": `bytes ${range.start}-${range.end - 1}/${info.size}`, status: "206" });
482
+ }
483
+ return setHTTPResultHeaders(result.data, headers);
372
484
  }
373
485
  }
374
486
 
375
487
  const largeUploadInfo = new Map<string, { account: string; bucketName: string; path: string }>();
376
488
 
489
+ // Access checks run as hooks on the register shape below, keyed off the call's arguments, so the
490
+ // method bodies don't each repeat them
491
+ const accountAccess: SocketFunctionHook = async (context) => {
492
+ await requireAccess(String(context.call.args[0]));
493
+ };
494
+ const uploadAccess: SocketFunctionHook = async (context) => {
495
+ let info = largeUploadInfo.get(String(context.call.args[0]));
496
+ // Unknown upload ids are handled by the methods themselves (throw / no-op)
497
+ if (!info) return;
498
+ await requireAccess(info.account);
499
+ };
500
+ const adminAccess: SocketFunctionHook = async () => {
501
+ requireAdmin();
502
+ };
503
+
377
504
  export const RemoteStorageController = SocketFunction.register(
378
505
  REMOTE_STORAGE_CLASS_GUID,
379
506
  new RemoteStorageControllerBase(),
380
507
  () => ({
508
+ ping: {},
381
509
  authenticate: {},
382
510
  requestAccess: {},
383
511
  getAccessState: {},
384
- adminListRequests: {},
385
- adminGrantAccess: {},
386
- ensureBucket: {},
387
- get: {},
388
- set: {},
389
- del: {},
390
- getInfo: {},
391
- findInfo: {},
392
- startLargeFile: {},
393
- uploadPart: {},
394
- finishLargeFile: {},
395
- cancelLargeFile: {},
396
- getPublicFile: {},
512
+ listRequestsForIP: { hooks: [accountAccess] },
513
+ grantAccess: {},
514
+ adminListRequests: { hooks: [adminAccess] },
515
+ adminGrantAccess: { hooks: [adminAccess] },
516
+ get: { hooks: [accountAccess] },
517
+ get2: { hooks: [accountAccess] },
518
+ set: { hooks: [accountAccess] },
519
+ del: { hooks: [accountAccess] },
520
+ getInfo: { hooks: [accountAccess] },
521
+ findInfo: { hooks: [accountAccess] },
522
+ getChangesAfter: { hooks: [accountAccess] },
523
+ getArchivesConfig: { hooks: [accountAccess] },
524
+ getIndexInfo: { hooks: [accountAccess] },
525
+ getSyncStatus: { hooks: [accountAccess] },
526
+ startLargeFile: { hooks: [accountAccess] },
527
+ uploadPart: { hooks: [uploadAccess] },
528
+ finishLargeFile: { hooks: [uploadAccess] },
529
+ cancelLargeFile: { hooks: [uploadAccess] },
530
+ httpEntry: {},
531
+ }),
532
+ () => ({
533
+ noClientHooks: true,
534
+ noDefaultHooks: true,
397
535
  })
398
536
  );
@@ -1 +1,7 @@
1
1
  import "./accessPage";
2
+ export type HostStorageServerConfig = {
3
+ url: string;
4
+ folder: string;
5
+ lowSpaceThresholdBytes?: number;
6
+ };
7
+ export declare function hostStorageServer(config: HostStorageServerConfig): Promise<void>;