nodio-cli 1.0.12 → 1.1.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.
- package/.env.example +5 -0
- package/README.md +2 -3
- package/package.json +6 -3
- package/scripts/backupDirect.js +58 -0
- package/scripts/backupFromLayer1.js +72 -0
- package/scripts/decodeAndReconstruct.js +130 -0
- package/scripts/downloadFromFilecoin.js +37 -0
- package/scripts/fundSynapse.js +57 -0
- package/services/filecoin.js +315 -0
- package/src/cli/index.js +0 -0
- package/src/node/index.js +0 -0
- package/src/server/index.js +44 -1
- package/src/server/models.js +2 -0
- package/src/server/routes.js +90 -5
- package/src/user/commands.js +257 -59
- package/src/user/index.js +0 -0
package/.env.example
CHANGED
|
@@ -4,3 +4,8 @@ NODIO_HEARTBEAT_INTERVAL_MS=30000
|
|
|
4
4
|
NODIO_OFFLINE_AFTER_MISSES=3
|
|
5
5
|
NODIO_MIN_REPLICAS=5
|
|
6
6
|
NODIO_EMERGENCY_REPLICA_FLOOR=2
|
|
7
|
+
NODIO_RELAY_POLL_INTERVAL_MS=1000
|
|
8
|
+
NODIO_WALLET_PRIVATE_KEY=
|
|
9
|
+
NODIO_WALLET_ADDRESS=
|
|
10
|
+
FILECOIN_NETWORK=calibration
|
|
11
|
+
FILECOIN_RPC_URL=https://api.calibration.node.glif.io/rpc/v1
|
package/README.md
CHANGED
|
@@ -122,6 +122,5 @@ npm run start:cli -- download \
|
|
|
122
122
|
- `POST /api/shards/placement-plan`
|
|
123
123
|
- `GET /api/files/:fileId/manifest`
|
|
124
124
|
|
|
125
|
-
## Next Step
|
|
126
|
-
|
|
127
|
-
Add authentication between user CLI, central server, and donor nodes for production security.
|
|
125
|
+
## Next Step( )
|
|
126
|
+
Integrating with nodio-drive.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodio-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Nodio distributed storage network",
|
|
5
5
|
"main": "src/server/index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -24,11 +24,14 @@
|
|
|
24
24
|
"author": "",
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"@filoz/synapse-sdk": "^0.41.0",
|
|
27
28
|
"axios": "^1.11.0",
|
|
28
29
|
"commander": "^14.0.1",
|
|
29
|
-
"
|
|
30
|
+
"cors": "^2.8.5",
|
|
31
|
+
"dotenv": "^17.4.2",
|
|
30
32
|
"express": "^5.1.0",
|
|
31
33
|
"mongoose": "^8.18.0",
|
|
32
|
-
"uuid": "^13.0.0"
|
|
34
|
+
"uuid": "^13.0.0",
|
|
35
|
+
"viem": "^2.48.11"
|
|
33
36
|
}
|
|
34
37
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
(async () => {
|
|
2
|
+
try {
|
|
3
|
+
require('dotenv').config();
|
|
4
|
+
const axios = require('axios');
|
|
5
|
+
const { privateKeyToAccount } = require('viem/accounts');
|
|
6
|
+
const { http } = require('viem');
|
|
7
|
+
const { calibration } = require('@filoz/synapse-core/chains');
|
|
8
|
+
|
|
9
|
+
const synapseSdk = await import('@filoz/synapse-sdk');
|
|
10
|
+
const Synapse = synapseSdk?.Synapse || synapseSdk?.default?.Synapse || synapseSdk?.default;
|
|
11
|
+
if (!Synapse || typeof Synapse.create !== 'function') {
|
|
12
|
+
throw new Error('Synapse.create not available');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const server = process.argv[2] || 'http://127.0.0.1:4000';
|
|
16
|
+
const fileId = process.argv[3];
|
|
17
|
+
if (!fileId) throw new Error('Usage: node backupDirect.js <serverUrl> <fileId>');
|
|
18
|
+
|
|
19
|
+
const privateKey = process.env.NODIO_WALLET_PRIVATE_KEY;
|
|
20
|
+
if (!privateKey) throw new Error('NODIO_WALLET_PRIVATE_KEY not set');
|
|
21
|
+
|
|
22
|
+
const rpcUrl = process.env.FILECOIN_RPC_URL || 'https://api.calibration.node.glif.io/rpc/v1';
|
|
23
|
+
const account = privateKeyToAccount(privateKey);
|
|
24
|
+
|
|
25
|
+
console.log('Init Synapse...');
|
|
26
|
+
const synapse = await Synapse.create({ chain: calibration, transport: http(rpcUrl), account, source: null, withCDN: false });
|
|
27
|
+
if (!synapse.storage || typeof synapse.storage.upload !== 'function') throw new Error('storage.upload not available');
|
|
28
|
+
|
|
29
|
+
console.log('Fetching manifest...');
|
|
30
|
+
const manifestResp = await axios.get(`${server}/api/files/${fileId}/manifest`);
|
|
31
|
+
const manifest = manifestResp.data;
|
|
32
|
+
const shards = manifest.shards || [];
|
|
33
|
+
|
|
34
|
+
const encryptedShardBuffers = [];
|
|
35
|
+
for (const shard of shards) {
|
|
36
|
+
const replica = (shard.replicas || [])[0];
|
|
37
|
+
if (!replica || !replica.url) throw new Error('no replica url');
|
|
38
|
+
const url = `${replica.url.replace(/\/+$/, '')}/shards/${shard.shardId}`;
|
|
39
|
+
const resp = await axios.get(url, { responseType: 'arraybuffer' });
|
|
40
|
+
encryptedShardBuffers.push(Buffer.from(resp.data));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const payload = Buffer.concat(encryptedShardBuffers);
|
|
44
|
+
console.log('Calling synapse.storage.upload (this may take a while)...');
|
|
45
|
+
try {
|
|
46
|
+
const result = await synapse.storage.upload(payload, { copies: 2 });
|
|
47
|
+
console.log('upload result:', result);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error('synapse.storage.upload error:', err);
|
|
50
|
+
if (err && err.cause) console.error('cause:', err.cause);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
process.exit(0);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error(error && error.stack ? error.stack : error);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
(async () => {
|
|
2
|
+
try {
|
|
3
|
+
require('dotenv').config();
|
|
4
|
+
const axios = require('axios');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { uploadToFilecoin } = require('../services/filecoin');
|
|
8
|
+
|
|
9
|
+
const server = process.argv[2] || process.env.NODIO_SERVER_URL || 'http://127.0.0.1:4000';
|
|
10
|
+
const fileId = process.argv[3];
|
|
11
|
+
if (!fileId) {
|
|
12
|
+
throw new Error('Usage: node backupFromLayer1.js <serverUrl> <fileId>');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log('Fetching manifest for', fileId);
|
|
16
|
+
const manifestResp = await axios.get(`${server}/api/files/${fileId}/manifest`, { timeout: 20000 });
|
|
17
|
+
const manifest = manifestResp.data;
|
|
18
|
+
const shards = manifest.shards || [];
|
|
19
|
+
|
|
20
|
+
const encryptedShardBuffers = [];
|
|
21
|
+
|
|
22
|
+
for (const shard of shards) {
|
|
23
|
+
const replicas = shard.replicas || [];
|
|
24
|
+
let fetched = null;
|
|
25
|
+
for (const replica of replicas) {
|
|
26
|
+
if (!replica.url) continue;
|
|
27
|
+
try {
|
|
28
|
+
const url = `${replica.url.replace(/\/+$/, '')}/shards/${shard.shardId}`;
|
|
29
|
+
const resp = await axios.get(url, { responseType: 'arraybuffer', timeout: 20000 });
|
|
30
|
+
const buf = Buffer.from(resp.data);
|
|
31
|
+
// simple checksum check
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
const sha = crypto.createHash('sha256').update(buf).digest('hex');
|
|
34
|
+
if (sha === shard.checksum) {
|
|
35
|
+
fetched = buf;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.warn('replica fetch failed', replica.nodeId, replica.url, err.message);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!fetched) {
|
|
44
|
+
throw new Error(`failed to fetch shard ${shard.shardId} from any replica`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
encryptedShardBuffers.push(fetched);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const encryptedFileBuffer = Buffer.concat(encryptedShardBuffers);
|
|
51
|
+
console.log('Uploading to Filecoin (this may take a while)...');
|
|
52
|
+
const cid = await uploadToFilecoin(encryptedFileBuffer, fileId);
|
|
53
|
+
console.log('uploadToFilecoin returned cid=', cid);
|
|
54
|
+
if (!cid) {
|
|
55
|
+
throw new Error('uploadToFilecoin failed or returned null');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await axios.post(`${server}/api/files/${fileId}/filecoin`, { filecoinCid: cid, filecoinBackedUp: true });
|
|
60
|
+
console.log('Persisted CID to server');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.warn('Failed to persist CID to server:', err.message || err);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log('Done');
|
|
66
|
+
process.exit(0);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error('error:', error.message || error);
|
|
69
|
+
if (error.stack) console.error(error.stack);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
(async () => {
|
|
2
|
+
try {
|
|
3
|
+
require('dotenv').config();
|
|
4
|
+
const axios = require('axios');
|
|
5
|
+
const fs = require('fs/promises');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { sha256Hex, decryptAes256Gcm } = require('../src/common/crypto');
|
|
8
|
+
|
|
9
|
+
const server = process.argv[2] || 'http://127.0.0.1:4000';
|
|
10
|
+
const fileId = process.argv[3];
|
|
11
|
+
const keyBase64 = process.argv[4];
|
|
12
|
+
|
|
13
|
+
if (!fileId) {
|
|
14
|
+
console.error('Usage: node scripts/decodeAndReconstruct.js <serverUrl> <fileId> [keyBase64]');
|
|
15
|
+
process.exit(2);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
console.log('Fetching manifest...');
|
|
19
|
+
const resp = await axios.get(`${server}/api/files/${fileId}/manifest`);
|
|
20
|
+
const manifest = resp.data || {};
|
|
21
|
+
const orderedShards = [...(manifest.shards || [])].sort((a, b) => a.order - b.order);
|
|
22
|
+
|
|
23
|
+
const rawPath = path.resolve(`./${fileId}.filecoin.raw`);
|
|
24
|
+
console.log('Reading raw payload from', rawPath);
|
|
25
|
+
const raw = await fs.readFile(rawPath);
|
|
26
|
+
|
|
27
|
+
let decoded = raw;
|
|
28
|
+
if (raw.length >= 8) {
|
|
29
|
+
const header = raw.subarray(0, 8);
|
|
30
|
+
const originalLength = Number(header.readBigUInt64BE(0));
|
|
31
|
+
const start = 8;
|
|
32
|
+
const end = start + originalLength;
|
|
33
|
+
if (originalLength >= 0 && end <= raw.length) {
|
|
34
|
+
decoded = raw.subarray(start, end);
|
|
35
|
+
console.log('Found 8-byte header; extracted original payload length', originalLength);
|
|
36
|
+
} else {
|
|
37
|
+
console.log('8-byte header present but length invalid; using full buffer');
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
console.log('No 8-byte header found; using full buffer');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const decodedPath = path.resolve(`./${fileId}.filecoin.decoded.raw`);
|
|
44
|
+
await fs.writeFile(decodedPath, decoded);
|
|
45
|
+
console.log('Wrote decoded payload to', decodedPath);
|
|
46
|
+
|
|
47
|
+
if (!orderedShards || orderedShards.length === 0) {
|
|
48
|
+
console.warn('No shard metadata in manifest; stopping after decode');
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sizes = orderedShards.map((s) => Number(s.sizeBytes || s.size || 0));
|
|
53
|
+
const totalSizes = sizes.reduce((a, b) => a + b, 0);
|
|
54
|
+
if (totalSizes > decoded.length) {
|
|
55
|
+
console.warn('Sum of shard sizes > decoded payload length; attempting best-effort split');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// split by sizes
|
|
59
|
+
const shards = [];
|
|
60
|
+
let offset = 0;
|
|
61
|
+
for (let i = 0; i < sizes.length; i++) {
|
|
62
|
+
const size = sizes[i] || 0;
|
|
63
|
+
const end = Math.min(offset + size, decoded.length);
|
|
64
|
+
const slice = decoded.subarray(offset, end);
|
|
65
|
+
shards.push(slice);
|
|
66
|
+
offset = end;
|
|
67
|
+
}
|
|
68
|
+
if (offset < decoded.length) {
|
|
69
|
+
console.warn('Extra bytes present after shard split; saved as remainder');
|
|
70
|
+
await fs.writeFile(path.resolve(`./${fileId}.filecoin.remainder.raw`), decoded.subarray(offset));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < shards.length; i++) {
|
|
74
|
+
const shard = shards[i];
|
|
75
|
+
const shardPath = path.resolve(`./${fileId}.shard.${i}.enc`);
|
|
76
|
+
await fs.writeFile(shardPath, shard);
|
|
77
|
+
const expectedChecksum = orderedShards[i]?.checksum;
|
|
78
|
+
if (expectedChecksum) {
|
|
79
|
+
const actual = sha256Hex(shard);
|
|
80
|
+
console.log(`shard ${i}: wrote ${shardPath} size=${shard.length} checksum match=${actual === expectedChecksum}`);
|
|
81
|
+
} else {
|
|
82
|
+
console.log(`shard ${i}: wrote ${shardPath} size=${shard.length}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!keyBase64) {
|
|
87
|
+
console.log('No key provided; stopping after saving encrypted shards. To decrypt, re-run with keyBase64 as third arg.');
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const keyBuffer = Buffer.from(keyBase64, 'base64');
|
|
92
|
+
if (keyBuffer.length !== 32) {
|
|
93
|
+
throw new Error('Provided keyBase64 does not decode to 32 bytes');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const decryptedParts = [];
|
|
97
|
+
|
|
98
|
+
const encryptionShards = (manifest.file && manifest.file.metadata && manifest.file.metadata.encryption && manifest.file.metadata.encryption.shards) || [];
|
|
99
|
+
const shardMetaMap = new Map((encryptionShards || []).map((s) => [s.shardId, s]));
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < shards.length; i++) {
|
|
102
|
+
const topShard = orderedShards[i];
|
|
103
|
+
if (!topShard) throw new Error(`missing top-level shard metadata for index ${i}`);
|
|
104
|
+
const shardMeta = shardMetaMap.get(topShard.shardId);
|
|
105
|
+
if (!shardMeta) throw new Error(`missing encryption metadata for shard ${topShard.shardId}`);
|
|
106
|
+
const iv = shardMeta.iv;
|
|
107
|
+
const authTag = shardMeta.authTag;
|
|
108
|
+
const encryptedBuffer = shards[i];
|
|
109
|
+
try {
|
|
110
|
+
const plain = decryptAes256Gcm(encryptedBuffer, keyBuffer, iv, authTag);
|
|
111
|
+
decryptedParts.push(plain);
|
|
112
|
+
console.log(`shard ${i}: decrypted ok size=${plain.length}`);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(`shard ${i}: decryption failed: ${err.message}`);
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const reconstructed = Buffer.concat(decryptedParts);
|
|
120
|
+
const originalName = manifest.file?.originalName || `${fileId}.reconstructed`;
|
|
121
|
+
const outPath = path.resolve(`./${originalName}`);
|
|
122
|
+
await fs.writeFile(outPath, reconstructed);
|
|
123
|
+
console.log('Wrote reconstructed file to', outPath, 'size=', reconstructed.length);
|
|
124
|
+
|
|
125
|
+
process.exit(0);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error(err && err.stack ? err.stack : err);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
(async () => {
|
|
2
|
+
try {
|
|
3
|
+
require('dotenv').config();
|
|
4
|
+
const axios = require('axios');
|
|
5
|
+
const fs = require('fs/promises');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const server = process.argv[2] || 'http://127.0.0.1:4000';
|
|
9
|
+
const fileId = process.argv[3];
|
|
10
|
+
if (!fileId) {
|
|
11
|
+
console.error('Usage: node scripts/downloadFromFilecoin.js <serverUrl> <fileId>');
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { retrieveFromFilecoin } = require('../services/filecoin');
|
|
16
|
+
|
|
17
|
+
console.log('Fetching manifest...');
|
|
18
|
+
const resp = await axios.get(`${server}/api/files/${fileId}/manifest`);
|
|
19
|
+
const manifest = resp.data || {};
|
|
20
|
+
const cid = manifest.file && manifest.file.filecoinCid;
|
|
21
|
+
if (!cid) {
|
|
22
|
+
throw new Error('filecoinCid not present on manifest');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log('Retrieving from Filecoin...', cid);
|
|
26
|
+
const buf = await retrieveFromFilecoin(cid);
|
|
27
|
+
if (!buf) throw new Error('retrieveFromFilecoin returned no data');
|
|
28
|
+
|
|
29
|
+
const outPath = path.resolve(`./${fileId}.filecoin.raw`);
|
|
30
|
+
await fs.writeFile(outPath, buf);
|
|
31
|
+
console.log('Saved filecoin payload to', outPath);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(err && err.stack ? err.stack : err);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
(async () => {
|
|
2
|
+
try {
|
|
3
|
+
require('dotenv').config();
|
|
4
|
+
const { http } = require('viem');
|
|
5
|
+
const { privateKeyToAccount } = require('viem/accounts');
|
|
6
|
+
const { calibration } = require('@filoz/synapse-core/chains');
|
|
7
|
+
|
|
8
|
+
const synapseSdk = await import('@filoz/synapse-sdk');
|
|
9
|
+
const Synapse = synapseSdk?.Synapse || synapseSdk?.default?.Synapse || synapseSdk?.default;
|
|
10
|
+
if (!Synapse || typeof Synapse.create !== 'function') {
|
|
11
|
+
throw new Error('Synapse.create not available from @filoz/synapse-sdk');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const privateKey = process.env.NODIO_WALLET_PRIVATE_KEY;
|
|
15
|
+
if (!privateKey) {
|
|
16
|
+
throw new Error('NODIO_WALLET_PRIVATE_KEY must be set in environment');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const rpcUrl = process.env.FILECOIN_RPC_URL || 'https://api.calibration.node.glif.io/rpc/v1';
|
|
20
|
+
|
|
21
|
+
const account = privateKeyToAccount(privateKey);
|
|
22
|
+
|
|
23
|
+
console.log('Initializing Synapse client (calibration)...');
|
|
24
|
+
const synapse = await Synapse.create({
|
|
25
|
+
chain: calibration,
|
|
26
|
+
transport: http(rpcUrl),
|
|
27
|
+
account,
|
|
28
|
+
source: null,
|
|
29
|
+
withCDN: false
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!synapse.payments || typeof synapse.payments.fundSync !== 'function') {
|
|
33
|
+
throw new Error('Payments service not available on Synapse client');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 3 USDFC with 18 decimals
|
|
37
|
+
const amount = BigInt('3000000000000000000');
|
|
38
|
+
|
|
39
|
+
console.log(`Submitting fund request for ${amount.toString()} (3 USDFC)...`);
|
|
40
|
+
|
|
41
|
+
const result = await synapse.payments.fundSync({ amount });
|
|
42
|
+
|
|
43
|
+
console.log('Fund transaction submitted.');
|
|
44
|
+
console.log('Hash:', result.hash);
|
|
45
|
+
console.log('Receipt:', result.receipt ? {
|
|
46
|
+
blockNumber: result.receipt.blockNumber,
|
|
47
|
+
transactionHash: result.receipt.transactionHash,
|
|
48
|
+
status: result.receipt.status
|
|
49
|
+
} : null);
|
|
50
|
+
|
|
51
|
+
process.exit(0);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('[fundSynapse] error:', error && error.message ? error.message : error);
|
|
54
|
+
if (error && error.stack) console.error(error.stack);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
const dotenv = require('dotenv');
|
|
2
|
+
const { formatUnits, http } = require('viem');
|
|
3
|
+
const { privateKeyToAccount } = require('viem/accounts');
|
|
4
|
+
const { calibration: filecoinCalibration } = require('@filoz/synapse-core/chains');
|
|
5
|
+
let synapseSdkPromise = null;
|
|
6
|
+
|
|
7
|
+
dotenv.config();
|
|
8
|
+
|
|
9
|
+
let synapseClientPromise = null;
|
|
10
|
+
const FILECOIN_MIN_PAYLOAD_BYTES = 127;
|
|
11
|
+
function resolveSynapseFactory(sdk) {
|
|
12
|
+
const candidate = sdk?.default || sdk;
|
|
13
|
+
|
|
14
|
+
if (candidate?.createSynapse && typeof candidate.createSynapse === 'function') {
|
|
15
|
+
return candidate.createSynapse;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (candidate?.Synapse?.create && typeof candidate.Synapse.create === 'function') {
|
|
19
|
+
return candidate.Synapse.create.bind(candidate.Synapse);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (candidate?.Synapse && typeof candidate.Synapse === 'function') {
|
|
23
|
+
return async (options) => new candidate.Synapse(options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof candidate === 'function') {
|
|
27
|
+
return async (options) => new candidate(options);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new Error('synapse sdk factory not found');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function getSynapseSdk() {
|
|
34
|
+
if (!synapseSdkPromise) {
|
|
35
|
+
synapseSdkPromise = import('@filoz/synapse-sdk');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return synapseSdkPromise;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeCid(value) {
|
|
42
|
+
if (!value) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof value === 'string') {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Handle CID objects (has toString() method)
|
|
51
|
+
if (value && typeof value.toString === 'function' && (value.constructor?.name === 'CID' || value.asCID)) {
|
|
52
|
+
return value.toString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof value.cid === 'string') {
|
|
56
|
+
return value.cid;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (value.cid && typeof value.cid.toString === 'function') {
|
|
60
|
+
return value.cid.toString();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof value.pieceCid === 'string') {
|
|
64
|
+
return value.pieceCid;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (value.pieceCid && typeof value.pieceCid.toString === 'function') {
|
|
68
|
+
return value.pieceCid.toString();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof value.id === 'string') {
|
|
72
|
+
return value.id;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeBuffer(value) {
|
|
79
|
+
if (!value) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (Buffer.isBuffer(value)) {
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (value.data && Buffer.isBuffer(value.data)) {
|
|
88
|
+
return value.data;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (value instanceof ArrayBuffer) {
|
|
92
|
+
return Buffer.from(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (ArrayBuffer.isView(value)) {
|
|
96
|
+
return Buffer.from(value.buffer);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof value === 'string') {
|
|
100
|
+
return Buffer.from(value, 'base64');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (value.data && typeof value.data === 'string') {
|
|
104
|
+
return Buffer.from(value.data, 'base64');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeBalance(value) {
|
|
111
|
+
if (value === null || value === undefined) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof value === 'number') {
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof value === 'string') {
|
|
120
|
+
const parsed = Number(value);
|
|
121
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof value === 'bigint') {
|
|
125
|
+
return Number(value) / 1_000_000;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof value === 'object') {
|
|
129
|
+
if (value.formatted) {
|
|
130
|
+
const parsed = Number(value.formatted);
|
|
131
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
132
|
+
}
|
|
133
|
+
if (value.value !== undefined) {
|
|
134
|
+
return normalizeBalance(value.value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function encodeFilecoinPayload(fileBuffer) {
|
|
142
|
+
const payloadLength = Buffer.byteLength(fileBuffer);
|
|
143
|
+
const header = Buffer.alloc(8);
|
|
144
|
+
header.writeBigUInt64BE(BigInt(payloadLength));
|
|
145
|
+
|
|
146
|
+
const wrapped = Buffer.concat([header, fileBuffer]);
|
|
147
|
+
if (wrapped.length >= FILECOIN_MIN_PAYLOAD_BYTES) {
|
|
148
|
+
return wrapped;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return Buffer.concat([wrapped, Buffer.alloc(FILECOIN_MIN_PAYLOAD_BYTES - wrapped.length)]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function decodeFilecoinPayload(fileBuffer) {
|
|
155
|
+
if (!Buffer.isBuffer(fileBuffer) || fileBuffer.length < 8) {
|
|
156
|
+
return fileBuffer;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const originalLength = Number(fileBuffer.readBigUInt64BE(0));
|
|
161
|
+
const start = 8;
|
|
162
|
+
const end = start + originalLength;
|
|
163
|
+
if (originalLength >= 0 && end <= fileBuffer.length) {
|
|
164
|
+
return fileBuffer.subarray(start, end);
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
return fileBuffer;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return fileBuffer;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function buildSynapseClient() {
|
|
174
|
+
const privateKey = process.env.NODIO_WALLET_PRIVATE_KEY;
|
|
175
|
+
if (!privateKey) {
|
|
176
|
+
throw new Error('NODIO_WALLET_PRIVATE_KEY is not set');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const rpcUrl =
|
|
180
|
+
process.env.FILECOIN_RPC_URL
|
|
181
|
+
|| 'https://api.calibration.node.glif.io/rpc/v1';
|
|
182
|
+
|
|
183
|
+
if (!rpcUrl) {
|
|
184
|
+
throw new Error('FILECOIN_RPC_URL is not set');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const account = privateKeyToAccount(privateKey);
|
|
188
|
+
|
|
189
|
+
const synapseSdk = await getSynapseSdk();
|
|
190
|
+
const Synapse = synapseSdk?.Synapse || synapseSdk?.default?.Synapse || synapseSdk?.default;
|
|
191
|
+
|
|
192
|
+
if (!Synapse || typeof Synapse.create !== 'function') {
|
|
193
|
+
throw new Error('synapse sdk Synapse.create not found');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return Synapse.create({
|
|
197
|
+
chain: filecoinCalibration,
|
|
198
|
+
transport: http(rpcUrl),
|
|
199
|
+
account,
|
|
200
|
+
source: null,
|
|
201
|
+
withCDN: false
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function getSynapseClient() {
|
|
206
|
+
if (!synapseClientPromise) {
|
|
207
|
+
synapseClientPromise = buildSynapseClient();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return synapseClientPromise;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function uploadToFilecoin(fileBuffer, fileId) {
|
|
214
|
+
if (!fileBuffer) {
|
|
215
|
+
throw new Error('file buffer is required');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const client = await getSynapseClient();
|
|
219
|
+
const label = fileId ? String(fileId) : undefined;
|
|
220
|
+
const payload = encodeFilecoinPayload(Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(fileBuffer));
|
|
221
|
+
|
|
222
|
+
if (!client || !client.storage || typeof client.storage.upload !== 'function') {
|
|
223
|
+
console.warn('[filecoin] storage.upload not available on synapse client');
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const maxAttempts = 3;
|
|
228
|
+
const baseBackoffMs = 500;
|
|
229
|
+
|
|
230
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
231
|
+
try {
|
|
232
|
+
const result = await client.storage.upload(payload, { copies: 2, name: label });
|
|
233
|
+
return normalizeCid(result);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
const msg = err && err.message ? err.message : String(err);
|
|
236
|
+
console.warn(`[filecoin] storage.upload attempt ${attempt}/${maxAttempts} failed: ${msg}`);
|
|
237
|
+
if (err && err.cause) {
|
|
238
|
+
console.warn(`[filecoin] cause: ${err.cause.message || err.cause}`);
|
|
239
|
+
}
|
|
240
|
+
if (attempt === maxAttempts) {
|
|
241
|
+
console.error('[filecoin] storage.upload failed - max attempts reached');
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
// exponential backoff
|
|
245
|
+
const wait = baseBackoffMs * (2 ** (attempt - 1));
|
|
246
|
+
// eslint-disable-next-line no-await-in-loop
|
|
247
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function retrieveFromFilecoin(cid) {
|
|
254
|
+
try {
|
|
255
|
+
if (!cid) {
|
|
256
|
+
throw new Error('cid is required');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const client = await getSynapseClient();
|
|
260
|
+
|
|
261
|
+
if (typeof client.retrieve === 'function') {
|
|
262
|
+
const result = await client.retrieve(cid);
|
|
263
|
+
return decodeFilecoinPayload(normalizeBuffer(result));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (client.storage?.retrieve && typeof client.storage.retrieve === 'function') {
|
|
267
|
+
const result = await client.storage.retrieve(cid);
|
|
268
|
+
return decodeFilecoinPayload(normalizeBuffer(result));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (client.storage?.download && typeof client.storage.download === 'function') {
|
|
272
|
+
const result = await client.storage.download({ pieceCid: cid });
|
|
273
|
+
return decodeFilecoinPayload(normalizeBuffer(result));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (client.file?.retrieve && typeof client.file.retrieve === 'function') {
|
|
277
|
+
const result = await client.file.retrieve(cid);
|
|
278
|
+
return decodeFilecoinPayload(normalizeBuffer(result));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (typeof client.fetch === 'function') {
|
|
282
|
+
const result = await client.fetch(cid);
|
|
283
|
+
return decodeFilecoinPayload(normalizeBuffer(result));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
console.warn('[filecoin] retrieve method not found on synapse client');
|
|
287
|
+
return null;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.error('[filecoin] retrieve failed', error.message);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function getWalletBalance() {
|
|
295
|
+
try {
|
|
296
|
+
const client = await getSynapseClient();
|
|
297
|
+
|
|
298
|
+
if (client?.payments && typeof client.payments.walletBalance === 'function') {
|
|
299
|
+
const balance = await client.payments.walletBalance({ token: 'USDFC' });
|
|
300
|
+
return Number.parseFloat(formatUnits(balance, 18));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
console.warn('[filecoin] balance method not found on synapse client');
|
|
304
|
+
return null;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
console.error('[filecoin] balance check failed', error.message);
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
uploadToFilecoin,
|
|
313
|
+
retrieveFromFilecoin,
|
|
314
|
+
getWalletBalance
|
|
315
|
+
};
|
package/src/cli/index.js
CHANGED
|
File without changes
|
package/src/node/index.js
CHANGED
|
File without changes
|
package/src/server/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const express = require('express');
|
|
3
|
+
const cors = require('cors');
|
|
3
4
|
const mongoose = require('mongoose');
|
|
4
5
|
const { getServerConfig } = require('./config');
|
|
5
6
|
const { buildRoutes } = require('./routes');
|
|
6
7
|
const { NodeModel } = require('./models');
|
|
7
8
|
const { markNodeOfflineAndRecover } = require('./services');
|
|
9
|
+
const { getWalletBalance } = require('../../services/filecoin');
|
|
8
10
|
|
|
9
11
|
async function startServer() {
|
|
10
12
|
const config = getServerConfig();
|
|
@@ -12,7 +14,28 @@ async function startServer() {
|
|
|
12
14
|
await mongoose.connect(config.mongoUri);
|
|
13
15
|
|
|
14
16
|
const app = express();
|
|
15
|
-
|
|
17
|
+
const corsAllowlist = new Set([
|
|
18
|
+
'https://nodio.me',
|
|
19
|
+
'https://drive.nodio.me',
|
|
20
|
+
'https://effective-space-rotary-phone-wrv6xg64p7w72wj-3000.app.github.dev'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const corsOptions = {
|
|
24
|
+
origin(origin, callback) {
|
|
25
|
+
if (!origin || corsAllowlist.has(origin)) {
|
|
26
|
+
callback(null, true);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
callback(new Error('Not allowed by CORS'));
|
|
30
|
+
},
|
|
31
|
+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
32
|
+
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
33
|
+
optionsSuccessStatus: 200
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
app.use(cors(corsOptions));
|
|
37
|
+
app.options(/.*/, cors(corsOptions));
|
|
38
|
+
app.use(express.json({ limit: '50mb' }));
|
|
16
39
|
app.use('/api', buildRoutes(config));
|
|
17
40
|
|
|
18
41
|
app.use((error, _req, res, _next) => {
|
|
@@ -50,6 +73,26 @@ async function startServer() {
|
|
|
50
73
|
app.listen(config.port, () => {
|
|
51
74
|
console.log(`Nodio central server listening on port ${config.port}`);
|
|
52
75
|
});
|
|
76
|
+
|
|
77
|
+
const dailyMs = 24 * 60 * 60 * 1000;
|
|
78
|
+
const checkBalance = async () => {
|
|
79
|
+
const balance = await getWalletBalance();
|
|
80
|
+
if (balance !== null && balance < 2) {
|
|
81
|
+
console.warn(`[filecoin] USDFC balance low: ${balance}`);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
checkBalance().catch((error) => {
|
|
87
|
+
console.error('[filecoin] balance check failed', error.message);
|
|
88
|
+
});
|
|
89
|
+
}, 5000);
|
|
90
|
+
|
|
91
|
+
setInterval(() => {
|
|
92
|
+
checkBalance().catch((error) => {
|
|
93
|
+
console.error('[filecoin] balance check failed', error.message);
|
|
94
|
+
});
|
|
95
|
+
}, dailyMs);
|
|
53
96
|
}
|
|
54
97
|
|
|
55
98
|
startServer().catch((error) => {
|
package/src/server/models.js
CHANGED
|
@@ -23,6 +23,8 @@ const fileSchema = new mongoose.Schema(
|
|
|
23
23
|
sizeBytes: { type: Number, required: true, min: 0 },
|
|
24
24
|
shardCount: { type: Number, required: true, min: 1 },
|
|
25
25
|
cipher: { type: String, required: true, default: 'aes-256-gcm' },
|
|
26
|
+
filecoinCid: { type: String, default: null },
|
|
27
|
+
filecoinBackedUp: { type: Boolean, default: false },
|
|
26
28
|
metadata: { type: mongoose.Schema.Types.Mixed, default: {} }
|
|
27
29
|
},
|
|
28
30
|
{ timestamps: true }
|
package/src/server/routes.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
ReplicationTaskModel,
|
|
10
10
|
RelayTaskModel
|
|
11
11
|
} = require('./models');
|
|
12
|
+
const { uploadToFilecoin } = require('../../services/filecoin');
|
|
12
13
|
const {
|
|
13
14
|
chooseDistinctOnlineNodes,
|
|
14
15
|
ensureEmergencyReplicasForShard
|
|
@@ -541,11 +542,91 @@ function buildRoutes(config) {
|
|
|
541
542
|
}
|
|
542
543
|
});
|
|
543
544
|
|
|
545
|
+
router.post('/files/:fileId/filecoin', async (req, res, next) => {
|
|
546
|
+
try {
|
|
547
|
+
const { fileId } = req.params;
|
|
548
|
+
const { filecoinCid, filecoinBackedUp } = req.body;
|
|
549
|
+
|
|
550
|
+
if (!fileId) {
|
|
551
|
+
return res.status(400).json({ error: 'fileId is required' });
|
|
552
|
+
}
|
|
553
|
+
if (!filecoinCid) {
|
|
554
|
+
return res.status(400).json({ error: 'filecoinCid is required' });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const file = await FileModel.findOneAndUpdate(
|
|
558
|
+
{ fileId },
|
|
559
|
+
{
|
|
560
|
+
$set: {
|
|
561
|
+
filecoinCid,
|
|
562
|
+
filecoinBackedUp: typeof filecoinBackedUp === 'boolean' ? filecoinBackedUp : true
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
{ new: true }
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
if (!file) {
|
|
569
|
+
return res.status(404).json({ error: 'file not found' });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
res.json({ ok: true, fileId: file.fileId, filecoinCid: file.filecoinCid });
|
|
573
|
+
} catch (error) {
|
|
574
|
+
next(error);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
router.post('/files/:fileId/filecoin/upload', async (req, res, next) => {
|
|
579
|
+
try {
|
|
580
|
+
const { fileId } = req.params;
|
|
581
|
+
const { dataBase64 } = req.body;
|
|
582
|
+
|
|
583
|
+
if (!fileId) {
|
|
584
|
+
return res.status(400).json({ error: 'fileId is required' });
|
|
585
|
+
}
|
|
586
|
+
if (!dataBase64 || typeof dataBase64 !== 'string') {
|
|
587
|
+
return res.status(400).json({ error: 'dataBase64 is required' });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const payload = Buffer.from(dataBase64, 'base64');
|
|
591
|
+
if (!payload || payload.length === 0) {
|
|
592
|
+
return res.status(400).json({ error: 'dataBase64 decoded to empty payload' });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const cid = await uploadToFilecoin(payload, fileId);
|
|
596
|
+
if (!cid) {
|
|
597
|
+
return res.status(502).json({ error: 'filecoin upload failed' });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const file = await FileModel.findOneAndUpdate(
|
|
601
|
+
{ fileId },
|
|
602
|
+
{
|
|
603
|
+
$set: {
|
|
604
|
+
filecoinCid: cid,
|
|
605
|
+
filecoinBackedUp: true
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
{ new: true }
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
if (!file) {
|
|
612
|
+
return res.status(404).json({ error: 'file not found' });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
res.json({ ok: true, fileId: file.fileId, filecoinCid: cid });
|
|
616
|
+
} catch (error) {
|
|
617
|
+
next(error);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
|
|
544
621
|
router.post('/shards/register', async (req, res, next) => {
|
|
545
622
|
try {
|
|
546
623
|
const { shardId, fileId, order, sizeBytes, checksum, nodeIds } = req.body;
|
|
547
|
-
if (!shardId || !fileId || !checksum
|
|
548
|
-
return res.status(400).json({ error: 'shardId, fileId,
|
|
624
|
+
if (!shardId || !fileId || !checksum) {
|
|
625
|
+
return res.status(400).json({ error: 'shardId, fileId, and checksum are required' });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (nodeIds !== undefined && !Array.isArray(nodeIds)) {
|
|
629
|
+
return res.status(400).json({ error: 'nodeIds must be an array when provided' });
|
|
549
630
|
}
|
|
550
631
|
|
|
551
632
|
await ShardModel.findOneAndUpdate(
|
|
@@ -562,7 +643,7 @@ function buildRoutes(config) {
|
|
|
562
643
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
563
644
|
);
|
|
564
645
|
|
|
565
|
-
const uniqueNodeIds = [...new Set(nodeIds)];
|
|
646
|
+
const uniqueNodeIds = [...new Set(Array.isArray(nodeIds) ? nodeIds : [])];
|
|
566
647
|
for (const nodeId of uniqueNodeIds) {
|
|
567
648
|
await ShardPlacementModel.updateOne(
|
|
568
649
|
{ shardId, nodeId },
|
|
@@ -580,7 +661,9 @@ function buildRoutes(config) {
|
|
|
580
661
|
);
|
|
581
662
|
}
|
|
582
663
|
|
|
583
|
-
|
|
664
|
+
if (uniqueNodeIds.length > 0) {
|
|
665
|
+
await ensureEmergencyReplicasForShard(shardId, config.emergencyReplicaFloor);
|
|
666
|
+
}
|
|
584
667
|
|
|
585
668
|
res.json({ ok: true });
|
|
586
669
|
} catch (error) {
|
|
@@ -730,7 +813,9 @@ function buildRoutes(config) {
|
|
|
730
813
|
sizeBytes: file.sizeBytes,
|
|
731
814
|
shardCount: file.shardCount,
|
|
732
815
|
cipher: file.cipher,
|
|
733
|
-
metadata: file.metadata
|
|
816
|
+
metadata: file.metadata,
|
|
817
|
+
filecoinCid: file.filecoinCid || null,
|
|
818
|
+
filecoinBackedUp: Boolean(file.filecoinBackedUp)
|
|
734
819
|
},
|
|
735
820
|
shards: shardManifests
|
|
736
821
|
});
|
package/src/user/commands.js
CHANGED
|
@@ -5,6 +5,7 @@ const axios = require('axios');
|
|
|
5
5
|
const { v4: uuidv4 } = require('uuid');
|
|
6
6
|
const { createApiClient, normalizeUrl } = require('./client');
|
|
7
7
|
const { encryptAes256Gcm, decryptAes256Gcm, sha256Hex } = require('../common/crypto');
|
|
8
|
+
const { retrieveFromFilecoin } = require('../../services/filecoin');
|
|
8
9
|
|
|
9
10
|
function splitBuffer(buffer, shardSizeBytes) {
|
|
10
11
|
if (shardSizeBytes <= 0) {
|
|
@@ -31,6 +32,157 @@ function sleep(ms) {
|
|
|
31
32
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
function splitEncryptedBufferBySizes(buffer, shardSizes) {
|
|
36
|
+
const slices = [];
|
|
37
|
+
let offset = 0;
|
|
38
|
+
|
|
39
|
+
for (const sizeBytes of shardSizes) {
|
|
40
|
+
const size = Number(sizeBytes) || 0;
|
|
41
|
+
const end = offset + size;
|
|
42
|
+
if (end > buffer.length) {
|
|
43
|
+
throw new Error('filecoin buffer is smaller than expected shard sizes');
|
|
44
|
+
}
|
|
45
|
+
slices.push(buffer.subarray(offset, end));
|
|
46
|
+
offset = end;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (offset < buffer.length) {
|
|
50
|
+
console.warn('[filecoin] extra bytes present after shard split; ignoring remainder');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return slices;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function decryptShardsFromBuffers(orderedShards, shardMetaMap, encryptedShardBuffers, keyBuffer) {
|
|
57
|
+
const plainParts = [];
|
|
58
|
+
|
|
59
|
+
for (let index = 0; index < orderedShards.length; index += 1) {
|
|
60
|
+
const shard = orderedShards[index];
|
|
61
|
+
const shardMeta = shardMetaMap.get(shard.shardId);
|
|
62
|
+
if (!shardMeta) {
|
|
63
|
+
throw new Error(`missing encryption metadata for shard ${shard.shardId}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const encryptedBuffer = encryptedShardBuffers[index];
|
|
67
|
+
if (!encryptedBuffer) {
|
|
68
|
+
throw new Error(`missing encrypted payload for shard ${shard.shardId}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (sha256Hex(encryptedBuffer) !== shard.checksum) {
|
|
72
|
+
throw new Error(`checksum mismatch for shard ${shard.shardId} from filecoin`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const plain = decryptAes256Gcm(encryptedBuffer, keyBuffer, shardMeta.iv, shardMeta.authTag);
|
|
77
|
+
plainParts.push(plain);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (String(error.message || '').includes('unsupported state or unable to authenticate data')) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`decryption failed for shard ${shard.shardId}: key is incorrect or metadata/key mismatch (double-check key-base64 copy)`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return Buffer.concat(plainParts);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function requestFilecoinBackup(api, fileBuffer, fileId) {
|
|
92
|
+
if (!fileBuffer || fileBuffer.length === 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await api.post(
|
|
98
|
+
`/files/${fileId}/filecoin/upload`,
|
|
99
|
+
{
|
|
100
|
+
dataBase64: fileBuffer.toString('base64')
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
timeout: 180000
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const message = error.response?.data?.error || error.message;
|
|
108
|
+
console.warn(`[filecoin] server upload failed for ${fileId}: ${message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fireAndForgetLayer1Reseed(api, fileId, orderedShards, encryptedShardBuffers, directTimeoutMs) {
|
|
113
|
+
void (async () => {
|
|
114
|
+
for (let index = 0; index < orderedShards.length; index += 1) {
|
|
115
|
+
const shard = orderedShards[index];
|
|
116
|
+
const encryptedBuffer = encryptedShardBuffers[index];
|
|
117
|
+
if (!encryptedBuffer || !Array.isArray(shard.replicas) || shard.replicas.length === 0) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const directWriteResults = await Promise.allSettled(
|
|
122
|
+
shard.replicas.map(async (replica) => {
|
|
123
|
+
const putUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
|
|
124
|
+
await axios.put(putUrl, encryptedBuffer, {
|
|
125
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
126
|
+
timeout: directTimeoutMs
|
|
127
|
+
});
|
|
128
|
+
return replica;
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const successfulReplicas = directWriteResults
|
|
133
|
+
.filter((result) => result.status === 'fulfilled')
|
|
134
|
+
.map((result) => result.value);
|
|
135
|
+
|
|
136
|
+
const failedReplicas = directWriteResults
|
|
137
|
+
.map((result, replicaIndex) => ({ result, replica: shard.replicas[replicaIndex] }))
|
|
138
|
+
.filter((entry) => entry.result.status === 'rejected')
|
|
139
|
+
.map((entry) => ({
|
|
140
|
+
nodeId: entry.replica.nodeId,
|
|
141
|
+
url: entry.replica.url,
|
|
142
|
+
error: entry.result.reason?.message || 'direct write failed'
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
if (failedReplicas.length > 0) {
|
|
146
|
+
try {
|
|
147
|
+
const relayResult = await relayStoreShard(api, {
|
|
148
|
+
shardId: shard.shardId,
|
|
149
|
+
fileId,
|
|
150
|
+
nodeIds: failedReplicas.map((replica) => replica.nodeId),
|
|
151
|
+
dataBuffer: encryptedBuffer
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const promotedRelayReplicas = shard.replicas.filter((replica) =>
|
|
155
|
+
relayResult.successfulNodeIds.includes(replica.nodeId)
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
for (const replica of promotedRelayReplicas) {
|
|
159
|
+
if (!successfulReplicas.some((item) => item.nodeId === replica.nodeId)) {
|
|
160
|
+
successfulReplicas.push(replica);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (relayError) {
|
|
164
|
+
console.warn(`[filecoin] reseed relay failed for shard ${shard.shardId}: ${relayError.message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (successfulReplicas.length > 0) {
|
|
169
|
+
try {
|
|
170
|
+
await api.post('/shards/register', {
|
|
171
|
+
shardId: shard.shardId,
|
|
172
|
+
fileId,
|
|
173
|
+
order: shard.order,
|
|
174
|
+
sizeBytes: shard.sizeBytes,
|
|
175
|
+
checksum: shard.checksum,
|
|
176
|
+
nodeIds: successfulReplicas.map((replica) => replica.nodeId)
|
|
177
|
+
});
|
|
178
|
+
} catch (registerError) {
|
|
179
|
+
console.warn(`[filecoin] reseed register failed for shard ${shard.shardId}: ${registerError.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
184
|
+
}
|
|
185
|
+
|
|
34
186
|
async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs = 45000, pollMs = 200 }) {
|
|
35
187
|
const opId = uuidv4();
|
|
36
188
|
await api.post('/relay/shards/store', {
|
|
@@ -147,27 +299,43 @@ async function uploadFile(options) {
|
|
|
147
299
|
});
|
|
148
300
|
|
|
149
301
|
const encryptionShardMeta = [];
|
|
302
|
+
const encryptedShardBuffers = [];
|
|
150
303
|
|
|
151
304
|
for (let order = 0; order < chunks.length; order += 1) {
|
|
152
305
|
const shardId = `${fileId}-shard-${order}-${uuidv4().slice(0, 8)}`;
|
|
153
306
|
const encrypted = encryptAes256Gcm(chunks[order], keyBuffer);
|
|
154
307
|
const checksum = sha256Hex(encrypted.cipherText);
|
|
308
|
+
encryptedShardBuffers[order] = encrypted.cipherText;
|
|
155
309
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
310
|
+
let plannedReplicas = [];
|
|
311
|
+
try {
|
|
312
|
+
const placementResponse = await api.post('/shards/placement-plan', {
|
|
313
|
+
shardId,
|
|
314
|
+
sizeBytes: encrypted.cipherText.length,
|
|
315
|
+
replicas
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
plannedReplicas = placementResponse.data.replicas || [];
|
|
319
|
+
if (plannedReplicas.length < replicas) {
|
|
320
|
+
console.warn(`[upload] placement returned ${plannedReplicas.length}/${replicas} replicas for ${shardId}`);
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
const apiMessage = error.response?.data?.error;
|
|
324
|
+
const statusCode = error.response?.status;
|
|
325
|
+
if (statusCode === 409 && String(apiMessage || '').startsWith('insufficient_online_nodes')) {
|
|
326
|
+
console.warn(`[upload] placement unavailable for ${shardId}: ${apiMessage}`);
|
|
327
|
+
plannedReplicas = [];
|
|
328
|
+
} else {
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
165
331
|
}
|
|
166
332
|
|
|
167
333
|
let successfulReplicas = [];
|
|
168
334
|
let failedReplicas = [];
|
|
169
335
|
|
|
170
|
-
if (
|
|
336
|
+
if (plannedReplicas.length === 0) {
|
|
337
|
+
console.warn(`[upload] no donor replicas available for shard ${shardId}; continuing with Filecoin only`);
|
|
338
|
+
} else if (relayFirst) {
|
|
171
339
|
failedReplicas = plannedReplicas.map((replica) => ({
|
|
172
340
|
nodeId: replica.nodeId,
|
|
173
341
|
url: replica.url,
|
|
@@ -216,7 +384,8 @@ async function uploadFile(options) {
|
|
|
216
384
|
}
|
|
217
385
|
|
|
218
386
|
if (successfulReplicas.length === 0) {
|
|
219
|
-
|
|
387
|
+
const failures = failedReplicas.map((r) => `${r.url} (${r.error})`).join(', ');
|
|
388
|
+
console.warn(`[upload] shard ${shardId} stored on 0 donors${failures ? `: ${failures}` : ''}`);
|
|
220
389
|
}
|
|
221
390
|
|
|
222
391
|
if (failedReplicas.length > 0) {
|
|
@@ -256,6 +425,11 @@ async function uploadFile(options) {
|
|
|
256
425
|
}
|
|
257
426
|
});
|
|
258
427
|
|
|
428
|
+
// Upload to Filecoin via the central server (uses server-side wallet key)
|
|
429
|
+
const encryptedFileBuffer = Buffer.concat(encryptedShardBuffers);
|
|
430
|
+
console.log(`[filecoin] uploading to Filecoin via central server (this may take a while)...`);
|
|
431
|
+
await requestFilecoinBackup(api, encryptedFileBuffer, fileId);
|
|
432
|
+
|
|
259
433
|
console.log(`Upload complete`);
|
|
260
434
|
console.log(`fileId: ${fileId}`);
|
|
261
435
|
console.log(`originalName: ${originalName}`);
|
|
@@ -290,70 +464,94 @@ async function downloadFile(options) {
|
|
|
290
464
|
const shardMetaMap = new Map((encryption.shards || []).map((entry) => [entry.shardId, entry]));
|
|
291
465
|
|
|
292
466
|
const orderedShards = [...(manifest.shards || [])].sort((a, b) => a.order - b.order);
|
|
293
|
-
|
|
467
|
+
let reconstructed = null;
|
|
294
468
|
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
if (!shardMeta) {
|
|
298
|
-
throw new Error(`missing encryption metadata for shard ${shard.shardId}`);
|
|
299
|
-
}
|
|
469
|
+
try {
|
|
470
|
+
const plainParts = [];
|
|
300
471
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
472
|
+
for (const shard of orderedShards) {
|
|
473
|
+
const shardMeta = shardMetaMap.get(shard.shardId);
|
|
474
|
+
if (!shardMeta) {
|
|
475
|
+
throw new Error(`missing encryption metadata for shard ${shard.shardId}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let encryptedBuffer = null;
|
|
479
|
+
if (!relayFirst) {
|
|
480
|
+
const directFetchResults = await Promise.allSettled(
|
|
481
|
+
(shard.replicas || []).map(async (replica) => {
|
|
482
|
+
const getUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
|
|
483
|
+
const response = await axios.get(getUrl, {
|
|
484
|
+
responseType: 'arraybuffer',
|
|
485
|
+
timeout: directTimeoutMs
|
|
486
|
+
});
|
|
487
|
+
const candidate = Buffer.from(response.data);
|
|
488
|
+
if (sha256Hex(candidate) !== shard.checksum) {
|
|
489
|
+
throw new Error('checksum mismatch');
|
|
490
|
+
}
|
|
491
|
+
return candidate;
|
|
492
|
+
})
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const successfulDirectFetch = directFetchResults.find((result) => result.status === 'fulfilled');
|
|
496
|
+
if (successfulDirectFetch) {
|
|
497
|
+
encryptedBuffer = successfulDirectFetch.value;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
|
|
502
|
+
try {
|
|
503
|
+
const relayResult = await relayFetchShard(api, {
|
|
504
|
+
shardId: shard.shardId,
|
|
505
|
+
nodeIds: shard.replicas.map((replica) => replica.nodeId)
|
|
309
506
|
});
|
|
310
|
-
|
|
311
|
-
if (sha256Hex(
|
|
312
|
-
|
|
507
|
+
|
|
508
|
+
if (relayResult.data && sha256Hex(relayResult.data) === shard.checksum) {
|
|
509
|
+
encryptedBuffer = relayResult.data;
|
|
313
510
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
511
|
+
} catch (relayError) {
|
|
512
|
+
console.warn(`[download] relay fetch failed for shard ${shard.shardId}: ${relayError.message}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
317
515
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
encryptedBuffer = successfulDirectFetch.value;
|
|
516
|
+
if (!encryptedBuffer) {
|
|
517
|
+
throw new Error(`failed to fetch valid replica for shard ${shard.shardId}`);
|
|
321
518
|
}
|
|
322
|
-
}
|
|
323
519
|
|
|
324
|
-
if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
|
|
325
520
|
try {
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
521
|
+
const plain = decryptAes256Gcm(encryptedBuffer, keyBuffer, shardMeta.iv, shardMeta.authTag);
|
|
522
|
+
plainParts.push(plain);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (String(error.message || '').includes('unsupported state or unable to authenticate data')) {
|
|
525
|
+
throw new Error(
|
|
526
|
+
`decryption failed for shard ${shard.shardId}: key is incorrect or metadata/key mismatch (double-check key-base64 copy)`
|
|
527
|
+
);
|
|
333
528
|
}
|
|
334
|
-
|
|
335
|
-
console.warn(`[download] relay fetch failed for shard ${shard.shardId}: ${relayError.message}`);
|
|
529
|
+
throw error;
|
|
336
530
|
}
|
|
337
531
|
}
|
|
338
532
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
533
|
+
reconstructed = Buffer.concat(plainParts);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
console.warn(`[download] layer1 failed for ${fileId}: ${error.message}`);
|
|
342
536
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
plainParts.push(plain);
|
|
346
|
-
} catch (error) {
|
|
347
|
-
if (String(error.message || '').includes('unsupported state or unable to authenticate data')) {
|
|
348
|
-
throw new Error(
|
|
349
|
-
`decryption failed for shard ${shard.shardId}: key is incorrect or metadata/key mismatch (double-check key-base64 copy)`
|
|
350
|
-
);
|
|
351
|
-
}
|
|
537
|
+
const filecoinCid = manifest.file?.filecoinCid;
|
|
538
|
+
if (!filecoinCid) {
|
|
352
539
|
throw error;
|
|
353
540
|
}
|
|
354
|
-
}
|
|
355
541
|
|
|
356
|
-
|
|
542
|
+
const encryptedFileBuffer = await retrieveFromFilecoin(filecoinCid);
|
|
543
|
+
if (!encryptedFileBuffer) {
|
|
544
|
+
throw new Error('filecoin retrieval failed');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const encryptedShardBuffers = splitEncryptedBufferBySizes(
|
|
548
|
+
encryptedFileBuffer,
|
|
549
|
+
orderedShards.map((shard) => shard.sizeBytes)
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
reconstructed = decryptShardsFromBuffers(orderedShards, shardMetaMap, encryptedShardBuffers, keyBuffer);
|
|
553
|
+
fireAndForgetLayer1Reseed(api, fileId, orderedShards, encryptedShardBuffers, directTimeoutMs);
|
|
554
|
+
}
|
|
357
555
|
await fs.mkdir(path.dirname(output), { recursive: true });
|
|
358
556
|
await fs.writeFile(output, reconstructed);
|
|
359
557
|
|
package/src/user/index.js
CHANGED
|
File without changes
|