sliftutils 1.7.4 → 1.7.6

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,195 @@
1
+ module.allowclient = true;
2
+
3
+ import { SocketFunction } from "socket-function/SocketFunction";
4
+ import { timeInMinute } from "socket-function/src/misc";
5
+ import { delay } from "socket-function/src/batching";
6
+ import { getIdentityCA, loadIdentityCA, sign } from "../../misc/https/certs";
7
+ import { IArchives, ArchiveFileInfo } from "../IArchives";
8
+ import {
9
+ RemoteStorageController, REMOTE_STORAGE_CLASS_GUID, STORAGE_AUTH_PURPOSE,
10
+ STORAGE_NOT_AUTHENTICATED, STORAGE_ACCESS_DENIED,
11
+ } from "./storageController";
12
+
13
+ // A bucket on our remote storage server (storageServer.ts), used like ArchivesBackblaze. Works in
14
+ // Node.js and the browser. Authenticates with this machine's certs.ts identity; if the account
15
+ // hasn't trusted this machine yet it requests access, waits, and logs instructions every minute
16
+ // (calls block until access is granted).
17
+
18
+ const ACCESS_RETRY_DELAY = 1000 * 30;
19
+ const LARGE_FILE_PART_SIZE = 8 * 1024 * 1024;
20
+
21
+ export type ArchivesRemoteConfig = {
22
+ address: string;
23
+ port: number;
24
+ account: string;
25
+ bucketName: string;
26
+ // Public buckets are served over plain HTTPS GETs (getURL). Private buckets are API-access only.
27
+ public?: boolean;
28
+ // Fast mode: the server acknowledges writes once they are in memory, flushing to disk after
29
+ // writeDelay (default 5 minutes) and coalescing writes to the same file. A server crash loses
30
+ // writes that haven't flushed yet.
31
+ fast?: boolean;
32
+ writeDelay?: number;
33
+ };
34
+
35
+ export function buildPublicFileURL(config: { address: string; port: number; account: string; bucketName: string; path: string }): string {
36
+ let args = encodeURIComponent(JSON.stringify([config.account, config.bucketName, config.path]));
37
+ return `https://${config.address}:${config.port}/?classGuid=${REMOTE_STORAGE_CLASS_GUID}&functionName=getPublicFile&args=${args}`;
38
+ }
39
+
40
+ // Authenticates a connection to a storage server with this machine's certs.ts identity
41
+ export async function authenticateStorage(config: { address: string; port: number; nodeId: string }): Promise<{ machineId: string; ip: string }> {
42
+ // hostServer nodeIds are machine-specific, so connections by domain must target "any server
43
+ // at this address" (which is how browsers always connect)
44
+ SocketFunction.ENABLE_CLIENT_MODE = true;
45
+ let rootDomain = config.address.split(".").slice(-2).join(".");
46
+ await loadIdentityCA(rootDomain);
47
+ let ca = getIdentityCA(rootDomain);
48
+ let time = Date.now();
49
+ let signature = sign({ key: ca.key }, {
50
+ purpose: STORAGE_AUTH_PURPOSE,
51
+ time,
52
+ server: `${config.address}:${config.port}`,
53
+ });
54
+ return await RemoteStorageController.nodes[config.nodeId].authenticate({ certPem: ca.cert.toString(), time, signature });
55
+ }
56
+
57
+ export class ArchivesRemote implements IArchives {
58
+ constructor(private config: ArchivesRemoteConfig) {
59
+ // hostServer nodeIds are machine-specific, so connections by domain must target "any
60
+ // server at this address" (which is how browsers always connect)
61
+ SocketFunction.ENABLE_CLIENT_MODE = true;
62
+ }
63
+
64
+ private nodeId = SocketFunction.connect({ address: this.config.address, port: this.config.port });
65
+ private controller = RemoteStorageController.nodes[this.nodeId];
66
+ private setupDone = false;
67
+ private lastDeniedLog = 0;
68
+
69
+ public getDebugName() {
70
+ return `remoteStorage/${this.config.address}:${this.config.port}/${this.config.account}/${this.config.bucketName}`;
71
+ }
72
+
73
+ private async authenticate(): Promise<void> {
74
+ await authenticateStorage({ address: this.config.address, port: this.config.port, nodeId: this.nodeId });
75
+ }
76
+
77
+ // Runs a call, authenticating (and re-authenticating after reconnects) as needed. Unlike
78
+ // call(), does NOT wait for account access.
79
+ private async callAuthed<T>(fnc: () => Promise<T>): Promise<T> {
80
+ try {
81
+ return await fnc();
82
+ } catch (e: any) {
83
+ if (!String(e.stack || e).includes(STORAGE_NOT_AUTHENTICATED)) throw e;
84
+ await this.authenticate();
85
+ return await fnc();
86
+ }
87
+ }
88
+
89
+ // Returns undefined if this machine has access to the account. Otherwise puts in an access
90
+ // request and returns the link to the page where access can be granted.
91
+ public async waitingForAccess(): Promise<string | undefined> {
92
+ let state = await this.callAuthed(() => this.controller.getAccessState(this.config.account));
93
+ if (state.hasAccess) return undefined;
94
+ await this.callAuthed(() => this.controller.requestAccess(this.config.account));
95
+ return `https://${this.config.address}:${this.config.port}/${this.config.account}`;
96
+ }
97
+
98
+ private async onAccessDenied(): Promise<void> {
99
+ let requested = await this.callAuthed(() => this.controller.requestAccess(this.config.account));
100
+ if (Date.now() - this.lastDeniedLog > timeInMinute) {
101
+ this.lastDeniedLog = Date.now();
102
+ console.log(`No access to storage account ${JSON.stringify(this.config.account)} on ${this.config.address}:${this.config.port} (our machine ${requested.machineId}, ip ${requested.ip}). Waiting for access to be granted. See https://${this.config.address}:${this.config.port}/${this.config.account} - or grant it with: ${requested.grantAccessCommand}`);
103
+ }
104
+ await delay(ACCESS_RETRY_DELAY);
105
+ }
106
+
107
+ private async ensureSetup(): Promise<void> {
108
+ if (this.setupDone) return;
109
+ await this.controller.ensureBucket(this.config.account, this.config.bucketName, {
110
+ public: this.config.public,
111
+ fast: this.config.fast,
112
+ writeDelay: this.config.writeDelay,
113
+ });
114
+ this.setupDone = true;
115
+ }
116
+
117
+ // Runs a call, authenticating (and re-authenticating after reconnects) and waiting for account
118
+ // access as needed.
119
+ private async call<T>(fnc: () => Promise<T>): Promise<T> {
120
+ while (true) {
121
+ try {
122
+ await this.ensureSetup();
123
+ return await fnc();
124
+ } catch (e: any) {
125
+ let message = String(e.stack || e);
126
+ if (message.includes(STORAGE_NOT_AUTHENTICATED)) {
127
+ this.setupDone = false;
128
+ await this.authenticate();
129
+ continue;
130
+ }
131
+ if (message.includes(STORAGE_ACCESS_DENIED)) {
132
+ this.setupDone = false;
133
+ await this.onAccessDenied();
134
+ continue;
135
+ }
136
+ throw e;
137
+ }
138
+ }
139
+ }
140
+
141
+ public async get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
142
+ let result = await this.call(() => this.controller.get(this.config.account, this.config.bucketName, fileName, config?.range));
143
+ return result && Buffer.from(result) || undefined;
144
+ }
145
+ public async set(fileName: string, data: Buffer): Promise<void> {
146
+ await this.call(() => this.controller.set(this.config.account, this.config.bucketName, fileName, data));
147
+ }
148
+ public async del(fileName: string): Promise<void> {
149
+ await this.call(() => this.controller.del(this.config.account, this.config.bucketName, fileName));
150
+ }
151
+ public async getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined> {
152
+ return await this.call(() => this.controller.getInfo(this.config.account, this.config.bucketName, fileName));
153
+ }
154
+ public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
155
+ return await this.call(() => this.controller.findInfo(this.config.account, this.config.bucketName, prefix, config));
156
+ }
157
+ public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
158
+ return (await this.findInfo(prefix, config)).map(x => x.path);
159
+ }
160
+
161
+ public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
162
+ // Ensure we're authenticated with access BEFORE consuming any data (the stream cannot be
163
+ // rewound, so we can't use the retry loop around the actual upload)
164
+ await this.call(() => this.controller.getInfo(this.config.account, this.config.bucketName, config.path));
165
+ let uploadId = await this.controller.startLargeFile(this.config.account, this.config.bucketName, config.path);
166
+ try {
167
+ while (true) {
168
+ let data = await config.getNextData();
169
+ if (!data) break;
170
+ for (let offset = 0; offset < data.length; offset += LARGE_FILE_PART_SIZE) {
171
+ await this.controller.uploadPart(uploadId, data.subarray(offset, offset + LARGE_FILE_PART_SIZE));
172
+ }
173
+ }
174
+ await this.controller.finishLargeFile(uploadId);
175
+ } catch (e) {
176
+ try {
177
+ await this.controller.cancelLargeFile(uploadId);
178
+ } catch { }
179
+ throw e;
180
+ }
181
+ }
182
+
183
+ public async getURL(path: string): Promise<string> {
184
+ if (!this.config.public) {
185
+ throw new Error(`getURL only works on public buckets (private buckets are API-access only). Bucket: ${this.getDebugName()}`);
186
+ }
187
+ return buildPublicFileURL({
188
+ address: this.config.address,
189
+ port: this.config.port,
190
+ account: this.config.account,
191
+ bucketName: this.config.bucketName,
192
+ path,
193
+ });
194
+ }
195
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,133 @@
1
+ module.allowclient = true;
2
+
3
+ import preact from "preact";
4
+ import { observable } from "mobx";
5
+ import { observer } from "../../render-utils/observer";
6
+ import { isNode } from "socket-function/src/misc";
7
+ import { css } from "typesafecss";
8
+ import { SocketFunction } from "socket-function/SocketFunction";
9
+ import { RemoteStorageController, AccessState } from "./storageController";
10
+ import { authenticateStorage } from "./ArchivesRemote";
11
+
12
+ // The storage server's access page. Visit https://<storageDomain>:<port>/<accountName> to request
13
+ // access to that account for this browser's machine identity. Shows the command to run on the
14
+ // storage machine to grant it, and once granted, the machines with (or requesting) access.
15
+
16
+ const REFRESH_INTERVAL = 1000 * 15;
17
+ const COPIED_RESET_DELAY = 2000;
18
+
19
+ @observer
20
+ class CopyableCommand extends preact.Component<{ command: string }> {
21
+ synced = observable({
22
+ copied: false,
23
+ });
24
+
25
+ render() {
26
+ let command = this.props.command;
27
+ return <div className={css.hbox(8).alignItems("flex-start")}>
28
+ <button onClick={async () => {
29
+ await navigator.clipboard.writeText(command);
30
+ this.synced.copied = true;
31
+ setTimeout(() => { this.synced.copied = false; }, COPIED_RESET_DELAY);
32
+ }}>
33
+ {this.synced.copied && "Copied!" || "Copy"}
34
+ </button>
35
+ <code className={css.fontFamily("monospace").whiteSpace("pre-wrap")
36
+ .hsl(0, 0, 92).pad2(8, 4).borderRadius(4)
37
+ }>
38
+ {command}
39
+ </code>
40
+ </div>;
41
+ }
42
+ }
43
+
44
+ @observer
45
+ class AccessPage extends preact.Component {
46
+ synced = observable({
47
+ account: "",
48
+ state: undefined as AccessState | undefined,
49
+ error: "",
50
+ });
51
+
52
+ componentDidMount() {
53
+ let account = decodeURIComponent(location.pathname.split("/")[1] || "");
54
+ this.synced.account = account;
55
+ if (!account) return;
56
+ void (async () => {
57
+ while (true) {
58
+ try {
59
+ await this.refresh(account);
60
+ } catch (e) {
61
+ this.synced.error = String(e);
62
+ }
63
+ if (this.synced.state?.hasAccess) break;
64
+ await new Promise(resolve => setTimeout(resolve, REFRESH_INTERVAL));
65
+ }
66
+ })();
67
+ }
68
+
69
+ private authenticated = false;
70
+ private async refresh(account: string) {
71
+ let address = location.hostname;
72
+ let port = +location.port || 443;
73
+ let nodeId = SocketFunction.connect({ address, port });
74
+ let controller = RemoteStorageController.nodes[nodeId];
75
+ if (!this.authenticated) {
76
+ await authenticateStorage({ address, port, nodeId });
77
+ this.authenticated = true;
78
+ }
79
+ await controller.requestAccess(account);
80
+ this.synced.state = await controller.getAccessState(account);
81
+ this.synced.error = "";
82
+ }
83
+
84
+ render() {
85
+ let { account, state, error } = this.synced;
86
+ if (!account) {
87
+ return <div className={css.vbox(8).pad2(16)}>
88
+ <div>Remote storage server.</div>
89
+ <div>Visit /(account name) to request access to an account for this browser.</div>
90
+ </div>;
91
+ }
92
+ return <div className={css.vbox(12).pad2(16)}>
93
+ <div>Storage account: {account}</div>
94
+ {error && <div>Error: {error}</div>}
95
+ {!state && !error && <div>Requesting access...</div>}
96
+ {state && !state.hasAccess && <div className={css.vbox(8)}>
97
+ <div>This machine ({state.machineId}, ip {state.ip}) does NOT have access yet.</div>
98
+ <div>An access request has been made. To grant it, run this single command (it sshes into the storage machine):</div>
99
+ <CopyableCommand command={state.grantAccessCommand || state.listAccessCommand} />
100
+ <div>This page rechecks every {REFRESH_INTERVAL / 1000} seconds.</div>
101
+ </div>}
102
+ {state && state.hasAccess && <div className={css.vbox(8)}>
103
+ <div>This machine ({state.machineId}, ip {state.ip}) has access.</div>
104
+ <div>Machines with or requesting access:</div>
105
+ <table>
106
+ <thead>
107
+ <tr>
108
+ <th>Machine</th>
109
+ <th>IP</th>
110
+ <th>Time</th>
111
+ <th>Status</th>
112
+ </tr>
113
+ </thead>
114
+ <tbody>
115
+ {(state.machines || []).map(machine => <tr key={machine.machineId}>
116
+ <td className={css.pad2(8, 2)}>{machine.machineId}</td>
117
+ <td className={css.pad2(8, 2)}>{machine.ip}</td>
118
+ <td className={css.pad2(8, 2)}>{new Date(machine.time).toLocaleString()}</td>
119
+ <td className={css.pad2(8, 2)}>{machine.trusted && "has access" || "requesting access"}</td>
120
+ </tr>)}
121
+ </tbody>
122
+ </table>
123
+ </div>}
124
+ </div>;
125
+ }
126
+ }
127
+
128
+ async function main() {
129
+ if (isNode()) return;
130
+ preact.render(<AccessPage />, document.body);
131
+ }
132
+
133
+ main().catch(console.error);
@@ -0,0 +1,56 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import { ArchiveFileInfo } from "../IArchives";
4
+ export declare const DEFAULT_FAST_WRITE_DELAY: number;
5
+ export type WriteConfig = {
6
+ fast?: boolean;
7
+ writeDelay?: number;
8
+ };
9
+ export declare class BlobStore {
10
+ private folder;
11
+ constructor(folder: string);
12
+ private memCache;
13
+ private overlay;
14
+ private writeQueue;
15
+ private openBlobs;
16
+ private largeUploads;
17
+ private nextLargeUploadId;
18
+ private currentBlobNumber;
19
+ private currentBlobOffset;
20
+ private currentBlobFd;
21
+ private index;
22
+ private deadBytes;
23
+ private blobsDir;
24
+ init: {
25
+ (): Promise<void>;
26
+ reset(): void;
27
+ set(newValue: Promise<void>): void;
28
+ };
29
+ private blobName;
30
+ private blobPath;
31
+ private getBlobHandle;
32
+ private closeBlobHandle;
33
+ private addDeadBytes;
34
+ private appendData;
35
+ private setIndexEntry;
36
+ set(key: string, data: Buffer, config?: WriteConfig): Promise<void>;
37
+ del(key: string, config?: WriteConfig): Promise<void>;
38
+ get(key: string, range?: {
39
+ start: number;
40
+ end: number;
41
+ }): Promise<Buffer | undefined>;
42
+ getInfo(key: string): Promise<{
43
+ writeTime: number;
44
+ size: number;
45
+ } | undefined>;
46
+ findInfo(prefix: string, config?: {
47
+ shallow?: boolean;
48
+ type?: "files" | "folders";
49
+ }): Promise<ArchiveFileInfo[]>;
50
+ startLargeUpload(): Promise<string>;
51
+ appendLargeUpload(id: string, data: Buffer): Promise<void>;
52
+ finishLargeUpload(id: string, key: string): Promise<void>;
53
+ cancelLargeUpload(id: string): Promise<void>;
54
+ private flushOverlay;
55
+ private compact;
56
+ }