nodio-cli 1.0.12 → 1.1.0
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 +43 -0
- package/src/server/models.js +2 -0
- package/src/server/routes.js +46 -5
- package/src/user/commands.js +273 -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.0
|
|
3
|
+
"version": "1.1.0",
|
|
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,6 +14,27 @@ async function startServer() {
|
|
|
12
14
|
await mongoose.connect(config.mongoUri);
|
|
13
15
|
|
|
14
16
|
const app = express();
|
|
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));
|
|
15
38
|
app.use(express.json({ limit: '10mb' }));
|
|
16
39
|
app.use('/api', buildRoutes(config));
|
|
17
40
|
|
|
@@ -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
|
@@ -541,11 +541,48 @@ function buildRoutes(config) {
|
|
|
541
541
|
}
|
|
542
542
|
});
|
|
543
543
|
|
|
544
|
+
router.post('/files/:fileId/filecoin', async (req, res, next) => {
|
|
545
|
+
try {
|
|
546
|
+
const { fileId } = req.params;
|
|
547
|
+
const { filecoinCid, filecoinBackedUp } = req.body;
|
|
548
|
+
|
|
549
|
+
if (!fileId) {
|
|
550
|
+
return res.status(400).json({ error: 'fileId is required' });
|
|
551
|
+
}
|
|
552
|
+
if (!filecoinCid) {
|
|
553
|
+
return res.status(400).json({ error: 'filecoinCid is required' });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const file = await FileModel.findOneAndUpdate(
|
|
557
|
+
{ fileId },
|
|
558
|
+
{
|
|
559
|
+
$set: {
|
|
560
|
+
filecoinCid,
|
|
561
|
+
filecoinBackedUp: typeof filecoinBackedUp === 'boolean' ? filecoinBackedUp : true
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
{ new: true }
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
if (!file) {
|
|
568
|
+
return res.status(404).json({ error: 'file not found' });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
res.json({ ok: true, fileId: file.fileId, filecoinCid: file.filecoinCid });
|
|
572
|
+
} catch (error) {
|
|
573
|
+
next(error);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
544
577
|
router.post('/shards/register', async (req, res, next) => {
|
|
545
578
|
try {
|
|
546
579
|
const { shardId, fileId, order, sizeBytes, checksum, nodeIds } = req.body;
|
|
547
|
-
if (!shardId || !fileId || !checksum
|
|
548
|
-
return res.status(400).json({ error: 'shardId, fileId,
|
|
580
|
+
if (!shardId || !fileId || !checksum) {
|
|
581
|
+
return res.status(400).json({ error: 'shardId, fileId, and checksum are required' });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (nodeIds !== undefined && !Array.isArray(nodeIds)) {
|
|
585
|
+
return res.status(400).json({ error: 'nodeIds must be an array when provided' });
|
|
549
586
|
}
|
|
550
587
|
|
|
551
588
|
await ShardModel.findOneAndUpdate(
|
|
@@ -562,7 +599,7 @@ function buildRoutes(config) {
|
|
|
562
599
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
563
600
|
);
|
|
564
601
|
|
|
565
|
-
const uniqueNodeIds = [...new Set(nodeIds)];
|
|
602
|
+
const uniqueNodeIds = [...new Set(Array.isArray(nodeIds) ? nodeIds : [])];
|
|
566
603
|
for (const nodeId of uniqueNodeIds) {
|
|
567
604
|
await ShardPlacementModel.updateOne(
|
|
568
605
|
{ shardId, nodeId },
|
|
@@ -580,7 +617,9 @@ function buildRoutes(config) {
|
|
|
580
617
|
);
|
|
581
618
|
}
|
|
582
619
|
|
|
583
|
-
|
|
620
|
+
if (uniqueNodeIds.length > 0) {
|
|
621
|
+
await ensureEmergencyReplicasForShard(shardId, config.emergencyReplicaFloor);
|
|
622
|
+
}
|
|
584
623
|
|
|
585
624
|
res.json({ ok: true });
|
|
586
625
|
} catch (error) {
|
|
@@ -730,7 +769,9 @@ function buildRoutes(config) {
|
|
|
730
769
|
sizeBytes: file.sizeBytes,
|
|
731
770
|
shardCount: file.shardCount,
|
|
732
771
|
cipher: file.cipher,
|
|
733
|
-
metadata: file.metadata
|
|
772
|
+
metadata: file.metadata,
|
|
773
|
+
filecoinCid: file.filecoinCid || null,
|
|
774
|
+
filecoinBackedUp: Boolean(file.filecoinBackedUp)
|
|
734
775
|
},
|
|
735
776
|
shards: shardManifests
|
|
736
777
|
});
|
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 { uploadToFilecoin, retrieveFromFilecoin } = require('../../services/filecoin');
|
|
8
9
|
|
|
9
10
|
function splitBuffer(buffer, shardSizeBytes) {
|
|
10
11
|
if (shardSizeBytes <= 0) {
|
|
@@ -31,6 +32,159 @@ 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
|
+
function fireAndForgetFilecoinUpload(api, fileBuffer, fileId) {
|
|
92
|
+
if (!fileBuffer || fileBuffer.length === 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
void (async () => {
|
|
97
|
+
const cid = await uploadToFilecoin(fileBuffer, fileId);
|
|
98
|
+
if (!cid) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await api.post(`/files/${fileId}/filecoin`, {
|
|
104
|
+
filecoinCid: cid,
|
|
105
|
+
filecoinBackedUp: true
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const message = error.response?.data?.error || error.message;
|
|
109
|
+
console.warn(`[filecoin] failed to persist cid for ${fileId}: ${message}`);
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function fireAndForgetLayer1Reseed(api, fileId, orderedShards, encryptedShardBuffers, directTimeoutMs) {
|
|
115
|
+
void (async () => {
|
|
116
|
+
for (let index = 0; index < orderedShards.length; index += 1) {
|
|
117
|
+
const shard = orderedShards[index];
|
|
118
|
+
const encryptedBuffer = encryptedShardBuffers[index];
|
|
119
|
+
if (!encryptedBuffer || !Array.isArray(shard.replicas) || shard.replicas.length === 0) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const directWriteResults = await Promise.allSettled(
|
|
124
|
+
shard.replicas.map(async (replica) => {
|
|
125
|
+
const putUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
|
|
126
|
+
await axios.put(putUrl, encryptedBuffer, {
|
|
127
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
128
|
+
timeout: directTimeoutMs
|
|
129
|
+
});
|
|
130
|
+
return replica;
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const successfulReplicas = directWriteResults
|
|
135
|
+
.filter((result) => result.status === 'fulfilled')
|
|
136
|
+
.map((result) => result.value);
|
|
137
|
+
|
|
138
|
+
const failedReplicas = directWriteResults
|
|
139
|
+
.map((result, replicaIndex) => ({ result, replica: shard.replicas[replicaIndex] }))
|
|
140
|
+
.filter((entry) => entry.result.status === 'rejected')
|
|
141
|
+
.map((entry) => ({
|
|
142
|
+
nodeId: entry.replica.nodeId,
|
|
143
|
+
url: entry.replica.url,
|
|
144
|
+
error: entry.result.reason?.message || 'direct write failed'
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
if (failedReplicas.length > 0) {
|
|
148
|
+
try {
|
|
149
|
+
const relayResult = await relayStoreShard(api, {
|
|
150
|
+
shardId: shard.shardId,
|
|
151
|
+
fileId,
|
|
152
|
+
nodeIds: failedReplicas.map((replica) => replica.nodeId),
|
|
153
|
+
dataBuffer: encryptedBuffer
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const promotedRelayReplicas = shard.replicas.filter((replica) =>
|
|
157
|
+
relayResult.successfulNodeIds.includes(replica.nodeId)
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
for (const replica of promotedRelayReplicas) {
|
|
161
|
+
if (!successfulReplicas.some((item) => item.nodeId === replica.nodeId)) {
|
|
162
|
+
successfulReplicas.push(replica);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (relayError) {
|
|
166
|
+
console.warn(`[filecoin] reseed relay failed for shard ${shard.shardId}: ${relayError.message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (successfulReplicas.length > 0) {
|
|
171
|
+
try {
|
|
172
|
+
await api.post('/shards/register', {
|
|
173
|
+
shardId: shard.shardId,
|
|
174
|
+
fileId,
|
|
175
|
+
order: shard.order,
|
|
176
|
+
sizeBytes: shard.sizeBytes,
|
|
177
|
+
checksum: shard.checksum,
|
|
178
|
+
nodeIds: successfulReplicas.map((replica) => replica.nodeId)
|
|
179
|
+
});
|
|
180
|
+
} catch (registerError) {
|
|
181
|
+
console.warn(`[filecoin] reseed register failed for shard ${shard.shardId}: ${registerError.message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
}
|
|
187
|
+
|
|
34
188
|
async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs = 45000, pollMs = 200 }) {
|
|
35
189
|
const opId = uuidv4();
|
|
36
190
|
await api.post('/relay/shards/store', {
|
|
@@ -147,27 +301,43 @@ async function uploadFile(options) {
|
|
|
147
301
|
});
|
|
148
302
|
|
|
149
303
|
const encryptionShardMeta = [];
|
|
304
|
+
const encryptedShardBuffers = [];
|
|
150
305
|
|
|
151
306
|
for (let order = 0; order < chunks.length; order += 1) {
|
|
152
307
|
const shardId = `${fileId}-shard-${order}-${uuidv4().slice(0, 8)}`;
|
|
153
308
|
const encrypted = encryptAes256Gcm(chunks[order], keyBuffer);
|
|
154
309
|
const checksum = sha256Hex(encrypted.cipherText);
|
|
310
|
+
encryptedShardBuffers[order] = encrypted.cipherText;
|
|
155
311
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
312
|
+
let plannedReplicas = [];
|
|
313
|
+
try {
|
|
314
|
+
const placementResponse = await api.post('/shards/placement-plan', {
|
|
315
|
+
shardId,
|
|
316
|
+
sizeBytes: encrypted.cipherText.length,
|
|
317
|
+
replicas
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
plannedReplicas = placementResponse.data.replicas || [];
|
|
321
|
+
if (plannedReplicas.length < replicas) {
|
|
322
|
+
console.warn(`[upload] placement returned ${plannedReplicas.length}/${replicas} replicas for ${shardId}`);
|
|
323
|
+
}
|
|
324
|
+
} catch (error) {
|
|
325
|
+
const apiMessage = error.response?.data?.error;
|
|
326
|
+
const statusCode = error.response?.status;
|
|
327
|
+
if (statusCode === 409 && String(apiMessage || '').startsWith('insufficient_online_nodes')) {
|
|
328
|
+
console.warn(`[upload] placement unavailable for ${shardId}: ${apiMessage}`);
|
|
329
|
+
plannedReplicas = [];
|
|
330
|
+
} else {
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
165
333
|
}
|
|
166
334
|
|
|
167
335
|
let successfulReplicas = [];
|
|
168
336
|
let failedReplicas = [];
|
|
169
337
|
|
|
170
|
-
if (
|
|
338
|
+
if (plannedReplicas.length === 0) {
|
|
339
|
+
console.warn(`[upload] no donor replicas available for shard ${shardId}; continuing with Filecoin only`);
|
|
340
|
+
} else if (relayFirst) {
|
|
171
341
|
failedReplicas = plannedReplicas.map((replica) => ({
|
|
172
342
|
nodeId: replica.nodeId,
|
|
173
343
|
url: replica.url,
|
|
@@ -216,7 +386,8 @@ async function uploadFile(options) {
|
|
|
216
386
|
}
|
|
217
387
|
|
|
218
388
|
if (successfulReplicas.length === 0) {
|
|
219
|
-
|
|
389
|
+
const failures = failedReplicas.map((r) => `${r.url} (${r.error})`).join(', ');
|
|
390
|
+
console.warn(`[upload] shard ${shardId} stored on 0 donors${failures ? `: ${failures}` : ''}`);
|
|
220
391
|
}
|
|
221
392
|
|
|
222
393
|
if (failedReplicas.length > 0) {
|
|
@@ -256,6 +427,25 @@ async function uploadFile(options) {
|
|
|
256
427
|
}
|
|
257
428
|
});
|
|
258
429
|
|
|
430
|
+
// Upload to Filecoin as default (synchronous with retries)
|
|
431
|
+
const encryptedFileBuffer = Buffer.concat(encryptedShardBuffers);
|
|
432
|
+
console.log(`[filecoin] uploading to Filecoin (this may take a while)...`);
|
|
433
|
+
const filecoinCid = await uploadToFilecoin(encryptedFileBuffer, fileId);
|
|
434
|
+
if (filecoinCid) {
|
|
435
|
+
try {
|
|
436
|
+
await api.post(`/files/${fileId}/filecoin`, {
|
|
437
|
+
filecoinCid,
|
|
438
|
+
filecoinBackedUp: true
|
|
439
|
+
});
|
|
440
|
+
console.log(`[filecoin] backup complete, cid: ${filecoinCid}`);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
const message = error.response?.data?.error || error.message;
|
|
443
|
+
console.warn(`[filecoin] warning: failed to persist cid: ${message}`);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
console.warn(`[filecoin] warning: upload to Filecoin failed, file backed up to Layer 1 only`);
|
|
447
|
+
}
|
|
448
|
+
|
|
259
449
|
console.log(`Upload complete`);
|
|
260
450
|
console.log(`fileId: ${fileId}`);
|
|
261
451
|
console.log(`originalName: ${originalName}`);
|
|
@@ -290,70 +480,94 @@ async function downloadFile(options) {
|
|
|
290
480
|
const shardMetaMap = new Map((encryption.shards || []).map((entry) => [entry.shardId, entry]));
|
|
291
481
|
|
|
292
482
|
const orderedShards = [...(manifest.shards || [])].sort((a, b) => a.order - b.order);
|
|
293
|
-
|
|
483
|
+
let reconstructed = null;
|
|
294
484
|
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
if (!shardMeta) {
|
|
298
|
-
throw new Error(`missing encryption metadata for shard ${shard.shardId}`);
|
|
299
|
-
}
|
|
485
|
+
try {
|
|
486
|
+
const plainParts = [];
|
|
300
487
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
488
|
+
for (const shard of orderedShards) {
|
|
489
|
+
const shardMeta = shardMetaMap.get(shard.shardId);
|
|
490
|
+
if (!shardMeta) {
|
|
491
|
+
throw new Error(`missing encryption metadata for shard ${shard.shardId}`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let encryptedBuffer = null;
|
|
495
|
+
if (!relayFirst) {
|
|
496
|
+
const directFetchResults = await Promise.allSettled(
|
|
497
|
+
(shard.replicas || []).map(async (replica) => {
|
|
498
|
+
const getUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
|
|
499
|
+
const response = await axios.get(getUrl, {
|
|
500
|
+
responseType: 'arraybuffer',
|
|
501
|
+
timeout: directTimeoutMs
|
|
502
|
+
});
|
|
503
|
+
const candidate = Buffer.from(response.data);
|
|
504
|
+
if (sha256Hex(candidate) !== shard.checksum) {
|
|
505
|
+
throw new Error('checksum mismatch');
|
|
506
|
+
}
|
|
507
|
+
return candidate;
|
|
508
|
+
})
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
const successfulDirectFetch = directFetchResults.find((result) => result.status === 'fulfilled');
|
|
512
|
+
if (successfulDirectFetch) {
|
|
513
|
+
encryptedBuffer = successfulDirectFetch.value;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
|
|
518
|
+
try {
|
|
519
|
+
const relayResult = await relayFetchShard(api, {
|
|
520
|
+
shardId: shard.shardId,
|
|
521
|
+
nodeIds: shard.replicas.map((replica) => replica.nodeId)
|
|
309
522
|
});
|
|
310
|
-
|
|
311
|
-
if (sha256Hex(
|
|
312
|
-
|
|
523
|
+
|
|
524
|
+
if (relayResult.data && sha256Hex(relayResult.data) === shard.checksum) {
|
|
525
|
+
encryptedBuffer = relayResult.data;
|
|
313
526
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
527
|
+
} catch (relayError) {
|
|
528
|
+
console.warn(`[download] relay fetch failed for shard ${shard.shardId}: ${relayError.message}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
317
531
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
encryptedBuffer = successfulDirectFetch.value;
|
|
532
|
+
if (!encryptedBuffer) {
|
|
533
|
+
throw new Error(`failed to fetch valid replica for shard ${shard.shardId}`);
|
|
321
534
|
}
|
|
322
|
-
}
|
|
323
535
|
|
|
324
|
-
if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
|
|
325
536
|
try {
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
537
|
+
const plain = decryptAes256Gcm(encryptedBuffer, keyBuffer, shardMeta.iv, shardMeta.authTag);
|
|
538
|
+
plainParts.push(plain);
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if (String(error.message || '').includes('unsupported state or unable to authenticate data')) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`decryption failed for shard ${shard.shardId}: key is incorrect or metadata/key mismatch (double-check key-base64 copy)`
|
|
543
|
+
);
|
|
333
544
|
}
|
|
334
|
-
|
|
335
|
-
console.warn(`[download] relay fetch failed for shard ${shard.shardId}: ${relayError.message}`);
|
|
545
|
+
throw error;
|
|
336
546
|
}
|
|
337
547
|
}
|
|
338
548
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
549
|
+
reconstructed = Buffer.concat(plainParts);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
console.warn(`[download] layer1 failed for ${fileId}: ${error.message}`);
|
|
342
552
|
|
|
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
|
-
}
|
|
553
|
+
const filecoinCid = manifest.file?.filecoinCid;
|
|
554
|
+
if (!filecoinCid) {
|
|
352
555
|
throw error;
|
|
353
556
|
}
|
|
354
|
-
}
|
|
355
557
|
|
|
356
|
-
|
|
558
|
+
const encryptedFileBuffer = await retrieveFromFilecoin(filecoinCid);
|
|
559
|
+
if (!encryptedFileBuffer) {
|
|
560
|
+
throw new Error('filecoin retrieval failed');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const encryptedShardBuffers = splitEncryptedBufferBySizes(
|
|
564
|
+
encryptedFileBuffer,
|
|
565
|
+
orderedShards.map((shard) => shard.sizeBytes)
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
reconstructed = decryptShardsFromBuffers(orderedShards, shardMetaMap, encryptedShardBuffers, keyBuffer);
|
|
569
|
+
fireAndForgetLayer1Reseed(api, fileId, orderedShards, encryptedShardBuffers, directTimeoutMs);
|
|
570
|
+
}
|
|
357
571
|
await fs.mkdir(path.dirname(output), { recursive: true });
|
|
358
572
|
await fs.writeFile(output, reconstructed);
|
|
359
573
|
|
package/src/user/index.js
CHANGED
|
File without changes
|