sliftutils 1.7.8 → 1.7.10
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.
- package/bin/storageserver.js +4 -0
- package/bundler/bundleEntry.ts +14 -10
- package/bundler/bundler.ts +6 -1
- package/{teststorage → examplestorage}/browser.tsx +18 -22
- package/{teststorage/server.ts → examplestorage/exampleserver.ts} +5 -5
- package/index.d.ts +528 -78
- package/misc/https/dns.d.ts +7 -3
- package/misc/https/dns.ts +4 -9
- package/misc/https/hostServer.d.ts +6 -3
- package/misc/https/hostServer.ts +6 -6
- package/package.json +2 -1
- package/spec.txt +77 -42
- package/storage/ArchivesDisk.d.ts +65 -0
- package/storage/ArchivesDisk.ts +365 -0
- package/storage/IArchives.d.ts +95 -1
- package/storage/IArchives.ts +146 -1
- package/storage/backblaze.d.ts +14 -2
- package/storage/backblaze.ts +18 -2
- package/storage/remoteStorage/ArchivesRemote.d.ts +29 -17
- package/storage/remoteStorage/ArchivesRemote.ts +63 -63
- package/storage/remoteStorage/ArchivesUrl.d.ts +46 -0
- package/storage/remoteStorage/ArchivesUrl.ts +74 -0
- package/storage/remoteStorage/accessPage.tsx +111 -28
- package/storage/remoteStorage/blobStore.d.ts +79 -25
- package/storage/remoteStorage/blobStore.ts +441 -287
- package/storage/remoteStorage/cliArgs.d.ts +1 -0
- package/storage/remoteStorage/cliArgs.ts +9 -0
- package/storage/remoteStorage/createArchives.d.ts +64 -0
- package/storage/remoteStorage/createArchives.ts +395 -0
- package/storage/remoteStorage/grantAccess.js +4 -0
- package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
- package/storage/remoteStorage/grantAccessCli.ts +27 -0
- package/storage/remoteStorage/remoteConfig.d.ts +21 -0
- package/storage/remoteStorage/remoteConfig.ts +94 -0
- package/storage/remoteStorage/storageController.d.ts +19 -27
- package/storage/remoteStorage/storageController.ts +205 -136
- package/storage/remoteStorage/storageServer.d.ts +11 -0
- package/storage/remoteStorage/storageServer.ts +80 -93
- package/storage/remoteStorage/storageServerCli.d.ts +1 -0
- package/storage/remoteStorage/storageServerCli.ts +41 -0
- package/storage/remoteStorage/storageServerState.d.ts +37 -0
- package/storage/remoteStorage/storageServerState.ts +358 -0
- package/testsite/server.ts +1 -1
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/// <reference types="node" />
|
|
3
|
-
import { ArchiveFileInfo } from "../IArchives";
|
|
4
|
-
import type { BlobStore } from "./blobStore";
|
|
5
|
-
import type { IStorage } from "../IStorage";
|
|
3
|
+
import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus } from "../IArchives";
|
|
6
4
|
export declare const REMOTE_STORAGE_CLASS_GUID = "RemoteStorageController-b7e42a91";
|
|
7
5
|
export declare const STORAGE_AUTH_PURPOSE = "remoteStorage-auth-1";
|
|
8
6
|
export declare const STORAGE_NOT_AUTHENTICATED = "REMOTE_STORAGE_NOT_AUTHENTICATED_cf2f7b1e";
|
|
@@ -25,33 +23,13 @@ export type TrustRecord = {
|
|
|
25
23
|
ip: string;
|
|
26
24
|
time: number;
|
|
27
25
|
};
|
|
28
|
-
export type BucketConfig = {
|
|
29
|
-
public?: boolean;
|
|
30
|
-
fast?: boolean;
|
|
31
|
-
writeDelay?: number;
|
|
32
|
-
};
|
|
33
26
|
export type AccessState = {
|
|
34
27
|
machineId: string;
|
|
35
28
|
ip: string;
|
|
36
29
|
hasAccess: boolean;
|
|
37
|
-
listAccessCommand: string;
|
|
38
30
|
grantAccessCommand?: string;
|
|
39
|
-
|
|
40
|
-
trusted: boolean;
|
|
41
|
-
})[];
|
|
42
|
-
};
|
|
43
|
-
export type StorageServerState = {
|
|
44
|
-
domain: string;
|
|
45
|
-
port: number;
|
|
46
|
-
rootDomain: string;
|
|
47
|
-
sshTarget: string;
|
|
48
|
-
serverCommand: string;
|
|
49
|
-
blobStore: BlobStore;
|
|
50
|
-
trust: IStorage<TrustRecord>;
|
|
51
|
-
requests: IStorage<AccessRequest[]>;
|
|
52
|
-
buckets: IStorage<BucketConfig>;
|
|
31
|
+
trustedMachines?: TrustRecord[];
|
|
53
32
|
};
|
|
54
|
-
export declare function setStorageServerState(state: StorageServerState): void;
|
|
55
33
|
export declare const RemoteStorageController: import("socket-function/SocketFunctionTypes").SocketRegistered<{
|
|
56
34
|
authenticate: (token: AuthToken) => Promise<{
|
|
57
35
|
machineId: string;
|
|
@@ -64,14 +42,22 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
|
|
|
64
42
|
grantAccessCommand: string;
|
|
65
43
|
}>;
|
|
66
44
|
getAccessState: (account: string) => Promise<AccessState>;
|
|
45
|
+
listRequestsForIP: (account: string, ip: string) => Promise<AccessRequest[]>;
|
|
46
|
+
grantAccess: (requestId: string) => Promise<TrustRecord>;
|
|
67
47
|
adminListRequests: (ip: string) => Promise<AccessRequest[]>;
|
|
68
48
|
adminGrantAccess: (requestId: string) => Promise<TrustRecord>;
|
|
69
|
-
ensureBucket: (account: string, bucketName: string, config: BucketConfig) => Promise<void>;
|
|
70
49
|
get: (account: string, bucketName: string, path: string, range?: {
|
|
71
50
|
start: number;
|
|
72
51
|
end: number;
|
|
73
52
|
}) => Promise<Buffer | undefined>;
|
|
74
|
-
|
|
53
|
+
get2: (account: string, bucketName: string, path: string, range?: {
|
|
54
|
+
start: number;
|
|
55
|
+
end: number;
|
|
56
|
+
}) => Promise<{
|
|
57
|
+
data: Buffer;
|
|
58
|
+
writeTime: number;
|
|
59
|
+
} | undefined>;
|
|
60
|
+
set: (account: string, bucketName: string, path: string, data: Buffer, lastModified?: number) => Promise<void>;
|
|
75
61
|
del: (account: string, bucketName: string, path: string) => Promise<void>;
|
|
76
62
|
getInfo: (account: string, bucketName: string, path: string) => Promise<{
|
|
77
63
|
writeTime: number;
|
|
@@ -81,9 +67,15 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
|
|
|
81
67
|
shallow?: boolean;
|
|
82
68
|
type?: "files" | "folders";
|
|
83
69
|
}) => Promise<ArchiveFileInfo[]>;
|
|
70
|
+
getChangesAfter: (account: string, bucketName: string, time: number) => Promise<ArchiveFileInfo[]>;
|
|
71
|
+
getArchivesConfig: (account: string, bucketName: string) => Promise<ArchivesConfig>;
|
|
72
|
+
getSyncStatus: (account: string, bucketName: string) => Promise<ArchivesSyncStatus>;
|
|
84
73
|
startLargeFile: (account: string, bucketName: string, path: string) => Promise<string>;
|
|
85
74
|
uploadPart: (uploadId: string, data: Buffer) => Promise<void>;
|
|
86
75
|
finishLargeFile: (uploadId: string) => Promise<void>;
|
|
87
76
|
cancelLargeFile: (uploadId: string) => Promise<void>;
|
|
88
|
-
|
|
77
|
+
httpEntry: (config?: {
|
|
78
|
+
requireCalls?: string[];
|
|
79
|
+
cacheTime?: number;
|
|
80
|
+
}) => Promise<Buffer>;
|
|
89
81
|
}>;
|
|
@@ -2,18 +2,27 @@ module.allowclient = true;
|
|
|
2
2
|
|
|
3
3
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
4
4
|
import { getNodeIdIP } from "socket-function/src/nodeCache";
|
|
5
|
-
import { setHTTPResultHeaders } from "socket-function/src/callHTTPHandler";
|
|
5
|
+
import { setHTTPResultHeaders, getCurrentHTTPRequest } from "socket-function/src/callHTTPHandler";
|
|
6
|
+
import { performLocalCall } from "socket-function/src/callManager";
|
|
7
|
+
import { RequireController } from "socket-function/require/RequireController";
|
|
6
8
|
import { timeInMinute } from "socket-function/src/misc";
|
|
7
9
|
import { getCommonName, getPublicIdentifier, getOwnMachineId, verify, verifyMachineIdForPublicKey } from "../../misc/https/certs";
|
|
8
|
-
import { ArchiveFileInfo } from "../IArchives";
|
|
9
|
-
import
|
|
10
|
-
import
|
|
10
|
+
import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus } from "../IArchives";
|
|
11
|
+
import { ROUTING_FILE } from "./remoteConfig";
|
|
12
|
+
import {
|
|
13
|
+
getStorageServerConfig, getTrust, getRequests, getLoadedBucket, writeBucketFile,
|
|
14
|
+
deleteBucketFile, assertWritesAllowed, assertMutable, LoadedBucket,
|
|
15
|
+
} from "./storageServerState";
|
|
11
16
|
|
|
12
17
|
// The remote storage server's API. Authentication uses certs.ts machine identities: a client
|
|
13
18
|
// proves it owns its machine key by signing a timestamped token (bound to this server, so tokens
|
|
14
19
|
// can't be replayed elsewhere), and the server then trusts that connection as that machineId.
|
|
15
20
|
// Access to an account is granted to specific machineIds, via a command line command run on the
|
|
16
21
|
// storage machine (see storageServer.ts).
|
|
22
|
+
//
|
|
23
|
+
// There is no bucket-creation API: a bucket exists iff its routing config (ROUTING_FILE) exists,
|
|
24
|
+
// and writing that file creates or reconfigures the bucket (see storageServerState.ts). Reads of
|
|
25
|
+
// nonexistent buckets return undefined / empty, same as reads of nonexistent files.
|
|
17
26
|
|
|
18
27
|
export const REMOTE_STORAGE_CLASS_GUID = "RemoteStorageController-b7e42a91";
|
|
19
28
|
export const STORAGE_AUTH_PURPOSE = "remoteStorage-auth-1";
|
|
@@ -43,47 +52,20 @@ export type TrustRecord = {
|
|
|
43
52
|
ip: string;
|
|
44
53
|
time: number;
|
|
45
54
|
};
|
|
46
|
-
export type BucketConfig = {
|
|
47
|
-
public?: boolean;
|
|
48
|
-
fast?: boolean;
|
|
49
|
-
writeDelay?: number;
|
|
50
|
-
};
|
|
51
55
|
export type AccessState = {
|
|
52
56
|
machineId: string;
|
|
53
57
|
ip: string;
|
|
54
58
|
hasAccess: boolean;
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
59
|
+
// A single ssh command, runnable from anywhere, that runs the grantAccess CLI on the storage
|
|
60
|
+
// machine to grant the caller's own pending request. Only set when the caller has a pending
|
|
61
|
+
// request (so an already-trusted caller has no need for it).
|
|
58
62
|
grantAccessCommand?: string;
|
|
59
|
-
// Only
|
|
60
|
-
|
|
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>;
|
|
63
|
+
// Only the machines that ALREADY have access. Pending requests are NEVER listed here — showing
|
|
64
|
+
// them would let a trusted user accidentally approve a random request. Callers see pending
|
|
65
|
+
// requests only by explicitly typing an IP into listRequestsForIP.
|
|
66
|
+
trustedMachines?: TrustRecord[];
|
|
75
67
|
};
|
|
76
68
|
|
|
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
69
|
// callerNodeId -> authenticated machineId. Connections are long-lived websockets, so a session
|
|
88
70
|
// lasts until the connection drops (clients re-authenticate on reconnect).
|
|
89
71
|
const sessions = new Map<string, string>();
|
|
@@ -107,6 +89,10 @@ function assertValidPath(path: string) {
|
|
|
107
89
|
if (!path || path.startsWith("/") || path.endsWith("/") || path.includes("//") || path.includes("\\") || path.includes("\x00")) {
|
|
108
90
|
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
91
|
}
|
|
92
|
+
// Paths are one-to-one with files on disk, so . and .. segments would escape the store folder
|
|
93
|
+
if (path.split("/").some(part => part === "." || part === "..")) {
|
|
94
|
+
throw new Error(`Invalid path ${JSON.stringify(path.slice(0, 200))}, paths cannot contain . or .. segments`);
|
|
95
|
+
}
|
|
110
96
|
}
|
|
111
97
|
|
|
112
98
|
function getCallerMachineId(): string {
|
|
@@ -121,8 +107,7 @@ function getCallerIP(): string {
|
|
|
121
107
|
return getNodeIdIP(SocketFunction.getCaller().nodeId);
|
|
122
108
|
}
|
|
123
109
|
function isAdmin(machineId: string): boolean {
|
|
124
|
-
|
|
125
|
-
return machineId === getOwnMachineId(state.rootDomain);
|
|
110
|
+
return machineId === getOwnMachineId(getStorageServerConfig().rootDomain);
|
|
126
111
|
}
|
|
127
112
|
function requireAdmin(): string {
|
|
128
113
|
let machineId = getCallerMachineId();
|
|
@@ -133,46 +118,35 @@ function requireAdmin(): string {
|
|
|
133
118
|
}
|
|
134
119
|
async function requireAccess(account: string): Promise<string> {
|
|
135
120
|
assertValidName(account, "account");
|
|
136
|
-
let state = getState();
|
|
137
121
|
let machineId = getCallerMachineId();
|
|
138
122
|
if (isAdmin(machineId)) return machineId;
|
|
139
|
-
let
|
|
123
|
+
let trust = await getTrust();
|
|
124
|
+
let trusted = await trust.get(`${account}|${machineId}`);
|
|
140
125
|
if (!trusted) {
|
|
141
|
-
|
|
126
|
+
let { domain, port } = getStorageServerConfig();
|
|
127
|
+
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
128
|
}
|
|
143
129
|
return machineId;
|
|
144
130
|
}
|
|
145
131
|
|
|
146
132
|
// A single command, runnable from anywhere, that sshes into the storage machine and runs the
|
|
147
|
-
//
|
|
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
|
-
}
|
|
133
|
+
// grantAccess CLI there
|
|
155
134
|
function getGrantAccessCommand(requestId: string): string {
|
|
156
|
-
|
|
135
|
+
let { sshTarget, serverCommand } = getStorageServerConfig();
|
|
136
|
+
return `ssh ${sshTarget} '${serverCommand} --requestId ${requestId}'`;
|
|
157
137
|
}
|
|
158
138
|
|
|
159
|
-
async function
|
|
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 {
|
|
139
|
+
async function getBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
|
|
165
140
|
assertValidName(account, "account");
|
|
166
141
|
assertValidName(bucketName, "bucket name");
|
|
167
|
-
|
|
168
|
-
return `${account}/${bucketName}/${path}`;
|
|
142
|
+
return await getLoadedBucket(account, bucketName);
|
|
169
143
|
}
|
|
170
144
|
|
|
171
145
|
class RemoteStorageControllerBase {
|
|
172
146
|
// Proves the caller owns the machine key for the machineId in its certificate. The signature
|
|
173
147
|
// must be fresh and bound to this server, so it cannot be replayed to (or from) other servers.
|
|
174
148
|
async authenticate(token: AuthToken): Promise<{ machineId: string; ip: string }> {
|
|
175
|
-
let
|
|
149
|
+
let { domain, port } = getStorageServerConfig();
|
|
176
150
|
let caller = SocketFunction.getCaller();
|
|
177
151
|
if (Math.abs(Date.now() - token.time) > AUTH_TIME_WINDOW) {
|
|
178
152
|
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 +154,7 @@ class RemoteStorageControllerBase {
|
|
|
180
154
|
verify(token.certPem, token.signature, {
|
|
181
155
|
purpose: STORAGE_AUTH_PURPOSE,
|
|
182
156
|
time: token.time,
|
|
183
|
-
server: `${
|
|
157
|
+
server: `${domain}:${port}`,
|
|
184
158
|
});
|
|
185
159
|
let machineId = getCommonName(token.certPem).split(".")[0];
|
|
186
160
|
if (!verifyMachineIdForPublicKey({ machineId, publicKey: getPublicIdentifier(token.certPem) })) {
|
|
@@ -199,10 +173,10 @@ class RemoteStorageControllerBase {
|
|
|
199
173
|
// IP, so the storage machine's admin can list them with --listAccess <ip> and grant one.
|
|
200
174
|
async requestAccess(account: string): Promise<{ machineId: string; ip: string; requestId: string; grantAccessCommand: string }> {
|
|
201
175
|
assertValidName(account, "account");
|
|
202
|
-
let state = getState();
|
|
203
176
|
let machineId = getCallerMachineId();
|
|
204
177
|
let ip = getCallerIP();
|
|
205
|
-
let
|
|
178
|
+
let requestsStorage = await getRequests();
|
|
179
|
+
let requests = await requestsStorage.get(ip) || [];
|
|
206
180
|
let existing = requests.find(x => x.account === account && x.machineId === machineId);
|
|
207
181
|
if (existing) {
|
|
208
182
|
existing.time = Date.now();
|
|
@@ -217,63 +191,87 @@ class RemoteStorageControllerBase {
|
|
|
217
191
|
requests.push(existing);
|
|
218
192
|
}
|
|
219
193
|
while (requests.length > MAX_REQUESTS_PER_IP) requests.shift();
|
|
220
|
-
await
|
|
194
|
+
await requestsStorage.set(ip, requests);
|
|
221
195
|
return { machineId, ip, requestId: existing.requestId, grantAccessCommand: getGrantAccessCommand(existing.requestId) };
|
|
222
196
|
}
|
|
223
197
|
|
|
224
198
|
async getAccessState(account: string): Promise<AccessState> {
|
|
225
199
|
assertValidName(account, "account");
|
|
226
|
-
let state = getState();
|
|
227
200
|
let machineId = getCallerMachineId();
|
|
228
201
|
let ip = getCallerIP();
|
|
229
|
-
let
|
|
230
|
-
let
|
|
202
|
+
let trust = await getTrust();
|
|
203
|
+
let hasAccess = isAdmin(machineId) || !!await trust.get(`${account}|${machineId}`);
|
|
204
|
+
let result: AccessState = { machineId, ip, hasAccess };
|
|
231
205
|
if (!hasAccess) {
|
|
232
|
-
let
|
|
206
|
+
let requests = await getRequests();
|
|
207
|
+
let ownRequest = (await requests.get(ip) || []).find(x => x.account === account && x.machineId === machineId);
|
|
233
208
|
if (ownRequest) {
|
|
234
209
|
result.grantAccessCommand = getGrantAccessCommand(ownRequest.requestId);
|
|
235
210
|
}
|
|
236
211
|
return result;
|
|
237
212
|
}
|
|
238
213
|
|
|
239
|
-
let
|
|
240
|
-
for (let
|
|
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()) {
|
|
214
|
+
let trustedMachines: TrustRecord[] = [];
|
|
215
|
+
for (let key of await trust.getKeys()) {
|
|
247
216
|
if (!key.startsWith(`${account}|`)) continue;
|
|
248
|
-
let record = await
|
|
249
|
-
if (
|
|
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
|
-
});
|
|
217
|
+
let record = await trust.get(key);
|
|
218
|
+
if (record) trustedMachines.push(record);
|
|
259
219
|
}
|
|
260
|
-
result.
|
|
220
|
+
result.trustedMachines = trustedMachines;
|
|
261
221
|
return result;
|
|
262
222
|
}
|
|
263
223
|
|
|
224
|
+
// Callable by any machine that has access to `account`. Returns pending access requests for the
|
|
225
|
+
// account that come from EXACTLY `ip`. Callers must type in an IP explicitly — the server never
|
|
226
|
+
// volunteers a list of requesting IPs, so a trusted user can't accidentally approve a random
|
|
227
|
+
// request from a machine they didn't mean to trust.
|
|
228
|
+
async listRequestsForIP(account: string, ip: string): Promise<AccessRequest[]> {
|
|
229
|
+
await requireAccess(account);
|
|
230
|
+
let requests = await getRequests();
|
|
231
|
+
return (await requests.get(ip) || []).filter(x => x.account === account);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Callable by any machine that has access to the request's account (or by the storage-machine
|
|
235
|
+
// admin). Grants the requested access; the caller must supply the specific requestId, which they
|
|
236
|
+
// only get by explicitly looking up requests for a specific IP.
|
|
237
|
+
async grantAccess(requestId: string): Promise<TrustRecord> {
|
|
238
|
+
// Must capture in the synchronous phase — SocketFunction.getCaller() only works before any await.
|
|
239
|
+
let callerMachineId = getCallerMachineId();
|
|
240
|
+
let trust = await getTrust();
|
|
241
|
+
let requests = await getRequests();
|
|
242
|
+
for (let ip of await requests.getKeys()) {
|
|
243
|
+
for (let request of await requests.get(ip) || []) {
|
|
244
|
+
if (request.requestId !== requestId) continue;
|
|
245
|
+
if (!isAdmin(callerMachineId) && !await trust.get(`${request.account}|${callerMachineId}`)) {
|
|
246
|
+
throw new Error(`${STORAGE_ACCESS_DENIED} Machine ${callerMachineId} has no access to account ${JSON.stringify(request.account)}`);
|
|
247
|
+
}
|
|
248
|
+
let record: TrustRecord = {
|
|
249
|
+
account: request.account,
|
|
250
|
+
machineId: request.machineId,
|
|
251
|
+
ip: request.ip,
|
|
252
|
+
time: Date.now(),
|
|
253
|
+
};
|
|
254
|
+
await trust.set(`${request.account}|${request.machineId}`, record);
|
|
255
|
+
return record;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
throw new Error(`No access request found with id ${JSON.stringify(requestId)}. It may have already been granted or expired.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
264
261
|
// Admin (must be run from the storage machine itself, which shares the server's machineId).
|
|
265
262
|
// Only returns requests for the given IP, so you cannot accidentally grant a request from an
|
|
266
263
|
// IP you didn't explicitly type in.
|
|
267
264
|
async adminListRequests(ip: string): Promise<AccessRequest[]> {
|
|
268
265
|
requireAdmin();
|
|
269
|
-
let
|
|
270
|
-
return await
|
|
266
|
+
let requests = await getRequests();
|
|
267
|
+
return await requests.get(ip) || [];
|
|
271
268
|
}
|
|
272
269
|
async adminGrantAccess(requestId: string): Promise<TrustRecord> {
|
|
273
270
|
requireAdmin();
|
|
274
|
-
let
|
|
275
|
-
|
|
276
|
-
|
|
271
|
+
let trust = await getTrust();
|
|
272
|
+
let requests = await getRequests();
|
|
273
|
+
for (let ip of await requests.getKeys()) {
|
|
274
|
+
for (let request of await requests.get(ip) || []) {
|
|
277
275
|
if (request.requestId !== requestId) continue;
|
|
278
276
|
let record: TrustRecord = {
|
|
279
277
|
account: request.account,
|
|
@@ -281,93 +279,159 @@ class RemoteStorageControllerBase {
|
|
|
281
279
|
ip: request.ip,
|
|
282
280
|
time: Date.now(),
|
|
283
281
|
};
|
|
284
|
-
await
|
|
282
|
+
await trust.set(`${request.account}|${request.machineId}`, record);
|
|
285
283
|
return record;
|
|
286
284
|
}
|
|
287
285
|
}
|
|
288
|
-
throw new Error(`No access request found with id ${JSON.stringify(requestId)}.
|
|
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);
|
|
286
|
+
throw new Error(`No access request found with id ${JSON.stringify(requestId)}. It may have already been granted or expired.`);
|
|
299
287
|
}
|
|
300
288
|
|
|
301
289
|
async get(account: string, bucketName: string, path: string, range?: { start: number; end: number }): Promise<Buffer | undefined> {
|
|
290
|
+
let result = await this.get2(account, bucketName, path, range);
|
|
291
|
+
return result && result.data || undefined;
|
|
292
|
+
}
|
|
293
|
+
async get2(account: string, bucketName: string, path: string, range?: { start: number; end: number }): Promise<{ data: Buffer; writeTime: number } | undefined> {
|
|
302
294
|
await requireAccess(account);
|
|
303
|
-
|
|
295
|
+
assertValidPath(path);
|
|
296
|
+
let bucket = await getBucket(account, bucketName);
|
|
297
|
+
if (!bucket) return undefined;
|
|
298
|
+
return await bucket.store.get2(path, { range });
|
|
304
299
|
}
|
|
305
|
-
async set(account: string, bucketName: string, path: string, data: Buffer): Promise<void> {
|
|
300
|
+
async set(account: string, bucketName: string, path: string, data: Buffer, lastModified?: number): Promise<void> {
|
|
306
301
|
await requireAccess(account);
|
|
307
|
-
|
|
308
|
-
|
|
302
|
+
assertValidName(bucketName, "bucket name");
|
|
303
|
+
assertValidPath(path);
|
|
304
|
+
// Handles bucket creation (writes of ROUTING_FILE), reconfiguration, fast mode, and
|
|
305
|
+
// immutability — see storageServerState.ts
|
|
306
|
+
await writeBucketFile(account, bucketName, path, Buffer.from(data), { lastModified });
|
|
309
307
|
}
|
|
310
308
|
async del(account: string, bucketName: string, path: string): Promise<void> {
|
|
311
309
|
await requireAccess(account);
|
|
312
|
-
|
|
313
|
-
|
|
310
|
+
assertValidName(bucketName, "bucket name");
|
|
311
|
+
assertValidPath(path);
|
|
312
|
+
await deleteBucketFile(account, bucketName, path);
|
|
314
313
|
}
|
|
315
314
|
async getInfo(account: string, bucketName: string, path: string): Promise<{ writeTime: number; size: number } | undefined> {
|
|
316
315
|
await requireAccess(account);
|
|
317
|
-
|
|
316
|
+
assertValidPath(path);
|
|
317
|
+
let bucket = await getBucket(account, bucketName);
|
|
318
|
+
if (!bucket) return undefined;
|
|
319
|
+
return await bucket.store.getInfo(path);
|
|
318
320
|
}
|
|
319
321
|
async findInfo(account: string, bucketName: string, prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
|
|
320
322
|
await requireAccess(account);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
323
|
+
let bucket = await getBucket(account, bucketName);
|
|
324
|
+
if (!bucket) return [];
|
|
325
|
+
return await bucket.store.findInfo(prefix, config);
|
|
326
|
+
}
|
|
327
|
+
// Fast (served from the store's BulkDatabase2 index, not a scan) — see IArchives.getChangesAfter
|
|
328
|
+
async getChangesAfter(account: string, bucketName: string, time: number): Promise<ArchiveFileInfo[]> {
|
|
329
|
+
await requireAccess(account);
|
|
330
|
+
let bucket = await getBucket(account, bucketName);
|
|
331
|
+
if (!bucket) return [];
|
|
332
|
+
if (!bucket.store.getChangesAfter) {
|
|
333
|
+
throw new Error(`Bucket ${account}/${bucketName} does not support getChangesAfter (rawDisk buckets have no index)`);
|
|
334
|
+
}
|
|
335
|
+
return await bucket.store.getChangesAfter(time);
|
|
336
|
+
}
|
|
337
|
+
async getArchivesConfig(account: string, bucketName: string): Promise<ArchivesConfig> {
|
|
338
|
+
await requireAccess(account);
|
|
339
|
+
let bucket = await getBucket(account, bucketName);
|
|
340
|
+
// Missing buckets say true, matching what they become once created (the default store type)
|
|
341
|
+
return { supportsChangesAfter: !bucket || !!bucket.store.getChangesAfter };
|
|
342
|
+
}
|
|
343
|
+
async getSyncStatus(account: string, bucketName: string): Promise<ArchivesSyncStatus> {
|
|
344
|
+
await requireAccess(account);
|
|
345
|
+
let bucket = await getBucket(account, bucketName);
|
|
346
|
+
if (!bucket) return { allScansComplete: true, indexSize: 0, sources: [] };
|
|
347
|
+
if (!bucket.store.getSyncStatus) {
|
|
348
|
+
throw new Error(`Bucket ${account}/${bucketName} does not support getSyncStatus (rawDisk buckets have no synchronization)`);
|
|
349
|
+
}
|
|
350
|
+
return await bucket.store.getSyncStatus();
|
|
325
351
|
}
|
|
326
352
|
|
|
327
353
|
async startLargeFile(account: string, bucketName: string, path: string): Promise<string> {
|
|
354
|
+
assertWritesAllowed();
|
|
328
355
|
await requireAccess(account);
|
|
329
356
|
// Validates now, so the upload doesn't fail at the end
|
|
330
|
-
|
|
331
|
-
let
|
|
357
|
+
assertValidPath(path);
|
|
358
|
+
let bucket = await getBucket(account, bucketName);
|
|
359
|
+
if (!bucket) {
|
|
360
|
+
throw new Error(`Bucket ${account}/${bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
361
|
+
}
|
|
362
|
+
await assertMutable(bucket, path);
|
|
363
|
+
let id = await bucket.store.startLargeUpload();
|
|
332
364
|
largeUploadInfo.set(id, { account, bucketName, path });
|
|
333
365
|
return id;
|
|
334
366
|
}
|
|
335
367
|
async uploadPart(uploadId: string, data: Buffer): Promise<void> {
|
|
368
|
+
assertWritesAllowed();
|
|
336
369
|
let info = largeUploadInfo.get(uploadId);
|
|
337
370
|
if (!info) throw new Error(`Unknown large upload ${uploadId}`);
|
|
338
371
|
await requireAccess(info.account);
|
|
339
|
-
await
|
|
372
|
+
let bucket = await getBucket(info.account, info.bucketName);
|
|
373
|
+
if (!bucket) throw new Error(`Bucket ${info.account}/${info.bucketName} no longer exists`);
|
|
374
|
+
await bucket.store.appendLargeUpload(uploadId, Buffer.from(data));
|
|
340
375
|
}
|
|
341
376
|
async finishLargeFile(uploadId: string): Promise<void> {
|
|
377
|
+
assertWritesAllowed();
|
|
342
378
|
let info = largeUploadInfo.get(uploadId);
|
|
343
379
|
if (!info) throw new Error(`Unknown large upload ${uploadId}`);
|
|
344
380
|
await requireAccess(info.account);
|
|
345
381
|
largeUploadInfo.delete(uploadId);
|
|
346
|
-
await
|
|
382
|
+
let bucket = await getBucket(info.account, info.bucketName);
|
|
383
|
+
if (!bucket) throw new Error(`Bucket ${info.account}/${info.bucketName} no longer exists`);
|
|
384
|
+
await bucket.store.finishLargeUpload(uploadId, info.path);
|
|
347
385
|
}
|
|
348
386
|
async cancelLargeFile(uploadId: string): Promise<void> {
|
|
349
387
|
let info = largeUploadInfo.get(uploadId);
|
|
350
388
|
if (!info) return;
|
|
351
389
|
await requireAccess(info.account);
|
|
352
390
|
largeUploadInfo.delete(uploadId);
|
|
353
|
-
await
|
|
391
|
+
let bucket = await getBucket(info.account, info.bucketName);
|
|
392
|
+
if (!bucket) return;
|
|
393
|
+
await bucket.store.cancelLargeUpload(uploadId);
|
|
354
394
|
}
|
|
355
395
|
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
396
|
+
// The server's single default HTTP route. /file/<account>/<bucketName>/<path> serves files from
|
|
397
|
+
// public buckets over plain GETs (see IArchives.getURL); every other path serves the access
|
|
398
|
+
// page (via RequireController.requireHTML — the path is the account name, see accessPage.tsx).
|
|
399
|
+
async httpEntry(config?: { requireCalls?: string[]; cacheTime?: number }): Promise<Buffer> {
|
|
400
|
+
// Both are keyed by the current call and must be captured synchronously, before any await
|
|
401
|
+
let caller = SocketFunction.getCaller();
|
|
402
|
+
let request = getCurrentHTTPRequest();
|
|
403
|
+
let pathname = new URL(request?.url || "/", "https://localhost").pathname;
|
|
404
|
+
if (!pathname.startsWith("/file/")) {
|
|
405
|
+
return await performLocalCall({
|
|
406
|
+
caller,
|
|
407
|
+
call: { nodeId: caller.nodeId, classGuid: RequireController._classGuid, functionName: "requireHTML", args: [config] },
|
|
408
|
+
}) as Buffer;
|
|
409
|
+
}
|
|
410
|
+
let parts = pathname.split("/").filter(x => x).map(decodeURIComponent);
|
|
411
|
+
let account = parts[1];
|
|
412
|
+
let bucketName = parts[2];
|
|
413
|
+
let filePath = parts.slice(3).join("/");
|
|
414
|
+
if (!account || !bucketName || !filePath) {
|
|
415
|
+
return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
|
|
416
|
+
}
|
|
417
|
+
assertValidName(account, "account");
|
|
418
|
+
assertValidName(bucketName, "bucket name");
|
|
419
|
+
assertValidPath(filePath);
|
|
420
|
+
let bucket = await getLoadedBucket(account, bucketName);
|
|
421
|
+
if (!bucket) {
|
|
422
|
+
return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
|
|
423
|
+
}
|
|
424
|
+
if (!bucket.self?.public) {
|
|
425
|
+
throw new Error(`Bucket ${account}/${bucketName} is not public, so its files cannot be read over plain URLs`);
|
|
363
426
|
}
|
|
364
|
-
let
|
|
365
|
-
if (!
|
|
366
|
-
|
|
427
|
+
let result = await bucket.store.get2(filePath);
|
|
428
|
+
if (!result) {
|
|
429
|
+
return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
|
|
367
430
|
}
|
|
368
|
-
let ext =
|
|
369
|
-
return setHTTPResultHeaders(data, {
|
|
431
|
+
let ext = filePath.split(".").pop() || "";
|
|
432
|
+
return setHTTPResultHeaders(result.data, {
|
|
370
433
|
"Content-Type": CONTENT_TYPES[ext.toLowerCase()] || "application/octet-stream",
|
|
434
|
+
"Last-Modified": new Date(result.writeTime).toUTCString(),
|
|
371
435
|
});
|
|
372
436
|
}
|
|
373
437
|
}
|
|
@@ -381,18 +445,23 @@ export const RemoteStorageController = SocketFunction.register(
|
|
|
381
445
|
authenticate: {},
|
|
382
446
|
requestAccess: {},
|
|
383
447
|
getAccessState: {},
|
|
448
|
+
listRequestsForIP: {},
|
|
449
|
+
grantAccess: {},
|
|
384
450
|
adminListRequests: {},
|
|
385
451
|
adminGrantAccess: {},
|
|
386
|
-
ensureBucket: {},
|
|
387
452
|
get: {},
|
|
453
|
+
get2: {},
|
|
388
454
|
set: {},
|
|
389
455
|
del: {},
|
|
390
456
|
getInfo: {},
|
|
391
457
|
findInfo: {},
|
|
458
|
+
getChangesAfter: {},
|
|
459
|
+
getArchivesConfig: {},
|
|
460
|
+
getSyncStatus: {},
|
|
392
461
|
startLargeFile: {},
|
|
393
462
|
uploadPart: {},
|
|
394
463
|
finishLargeFile: {},
|
|
395
464
|
cancelLargeFile: {},
|
|
396
|
-
|
|
465
|
+
httpEntry: {},
|
|
397
466
|
})
|
|
398
467
|
);
|
|
@@ -1 +1,12 @@
|
|
|
1
1
|
import "./accessPage";
|
|
2
|
+
export type HostStorageServerConfig = {
|
|
3
|
+
url: string;
|
|
4
|
+
folder: string;
|
|
5
|
+
cloudflareApiToken?: {
|
|
6
|
+
key: string;
|
|
7
|
+
} | {
|
|
8
|
+
path: string;
|
|
9
|
+
};
|
|
10
|
+
lowSpaceThresholdBytes?: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function hostStorageServer(config: HostStorageServerConfig): Promise<void>;
|