nodio-cli 1.0.0 → 1.0.2
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/package.json +3 -2
- package/src/cli/index.js +50 -0
- package/src/node/deviceIdentity.js +45 -0
- package/src/node/index.js +63 -9
- package/src/node/runtime.js +25 -1
- package/src/node/storage.js +69 -0
- package/src/server/models.js +2 -0
- package/src/server/routes.js +121 -25
- package/src/user/commands.js +20 -1
- package/src/user/index.js +12 -3
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodio-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Nodio distributed storage network",
|
|
5
5
|
"main": "src/server/index.js",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"bin": {
|
|
8
|
+
"nodio-cli": "src/cli/index.js",
|
|
8
9
|
"nodio-server": "src/server/index.js",
|
|
9
10
|
"nodio-node": "src/node/index.js",
|
|
10
11
|
"nodio": "src/user/index.js"
|
|
@@ -30,4 +31,4 @@
|
|
|
30
31
|
"mongoose": "^8.18.0",
|
|
31
32
|
"uuid": "^13.0.0"
|
|
32
33
|
}
|
|
33
|
-
}
|
|
34
|
+
}
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
|
|
5
|
+
const commandMap = {
|
|
6
|
+
'nodio-node': path.join(__dirname, '..', 'node', 'index.js'),
|
|
7
|
+
'nodio-server': path.join(__dirname, '..', 'server', 'index.js'),
|
|
8
|
+
nodio: path.join(__dirname, '..', 'user', 'index.js')
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function printHelp() {
|
|
12
|
+
console.log('Nodio CLI dispatcher');
|
|
13
|
+
console.log('');
|
|
14
|
+
console.log('Usage:');
|
|
15
|
+
console.log(' nodio-cli <command> [...args]');
|
|
16
|
+
console.log('');
|
|
17
|
+
console.log('Commands:');
|
|
18
|
+
console.log(' nodio-node Start donor node runtime');
|
|
19
|
+
console.log(' nodio-server Start central server');
|
|
20
|
+
console.log(' nodio User CLI (upload/download/delete)');
|
|
21
|
+
console.log('');
|
|
22
|
+
console.log('Example:');
|
|
23
|
+
console.log(' npx nodio-cli nodio-node 10gb');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const [command, ...restArgs] = process.argv.slice(2);
|
|
27
|
+
|
|
28
|
+
if (!command || command === '--help' || command === '-h') {
|
|
29
|
+
printHelp();
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const scriptPath = commandMap[command];
|
|
34
|
+
if (!scriptPath) {
|
|
35
|
+
console.error(`[nodio-cli] Unknown command: ${command}`);
|
|
36
|
+
printHelp();
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const child = spawn(process.execPath, [scriptPath, ...restArgs], {
|
|
41
|
+
stdio: 'inherit'
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
child.on('exit', (code, signal) => {
|
|
45
|
+
if (signal) {
|
|
46
|
+
process.kill(process.pid, signal);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
process.exit(code ?? 0);
|
|
50
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
const DEVICE_DIR = path.join(os.homedir(), '.nodio');
|
|
7
|
+
const DEVICE_FILE = path.join(DEVICE_DIR, 'device-identity.json');
|
|
8
|
+
|
|
9
|
+
async function readDeviceIdentity() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = await fs.readFile(DEVICE_FILE, 'utf-8');
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
14
|
+
} catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getOrCreateDeviceKey() {
|
|
20
|
+
const identity = await readDeviceIdentity();
|
|
21
|
+
if (identity.deviceKey) {
|
|
22
|
+
return identity.deviceKey;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const deviceKey = crypto.randomUUID();
|
|
26
|
+
await fs.mkdir(DEVICE_DIR, { recursive: true });
|
|
27
|
+
await fs.writeFile(
|
|
28
|
+
DEVICE_FILE,
|
|
29
|
+
JSON.stringify(
|
|
30
|
+
{
|
|
31
|
+
deviceKey,
|
|
32
|
+
createdAt: new Date().toISOString()
|
|
33
|
+
},
|
|
34
|
+
null,
|
|
35
|
+
2
|
|
36
|
+
),
|
|
37
|
+
'utf-8'
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return deviceKey;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
getOrCreateDeviceKey
|
|
45
|
+
};
|
package/src/node/index.js
CHANGED
|
@@ -1,31 +1,85 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const os = require('os');
|
|
4
|
+
const net = require('net');
|
|
4
5
|
const { Command } = require('commander');
|
|
5
|
-
const { v4: uuidv4 } = require('uuid');
|
|
6
6
|
const { NodioNodeRuntime } = require('./runtime');
|
|
7
7
|
|
|
8
8
|
const program = new Command();
|
|
9
9
|
|
|
10
|
+
function parseCapacityGb(value) {
|
|
11
|
+
if (value === undefined || value === null || value === '') {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const raw = String(value).trim().toLowerCase();
|
|
16
|
+
const match = raw.match(/^([0-9]+(?:\.[0-9]+)?)(gb)?$/);
|
|
17
|
+
if (!match) {
|
|
18
|
+
throw new Error('capacity must be a number or <number>gb, e.g. 10 or 10gb');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return Number(match[1]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function isPortFree(port) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const server = net.createServer();
|
|
27
|
+
server.unref();
|
|
28
|
+
|
|
29
|
+
server.once('error', () => resolve(false));
|
|
30
|
+
server.once('listening', () => {
|
|
31
|
+
server.close(() => resolve(true));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
server.listen(port, '127.0.0.1');
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function findFreePort(startPort, endPort) {
|
|
39
|
+
for (let port = startPort; port <= endPort; port += 1) {
|
|
40
|
+
// eslint-disable-next-line no-await-in-loop
|
|
41
|
+
if (await isPortFree(port)) {
|
|
42
|
+
return port;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
throw new Error(`no free port found in range ${startPort}-${endPort}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
10
49
|
program
|
|
11
50
|
.name('nodio-node')
|
|
12
51
|
.description('Nodio donor node CLI')
|
|
13
|
-
.
|
|
14
|
-
.option('--
|
|
52
|
+
.argument('[capacity]', 'optional capacity shorthand, e.g. 10gb')
|
|
53
|
+
.option('--node-id <id>', 'unique node ID (optional; auto-assigned and persisted if omitted)')
|
|
54
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
15
55
|
.option('--host <host>', 'host/IP exposed to network', '127.0.0.1')
|
|
16
|
-
.option('--port <port>', 'port to expose shard API
|
|
17
|
-
.option('--storage-dir <path>', 'local shard storage directory
|
|
56
|
+
.option('--port <port>', 'port to expose shard API (auto-picked when omitted)')
|
|
57
|
+
.option('--storage-dir <path>', 'local shard storage directory (defaults to ~/.nodio-nodes/node-<port>)')
|
|
18
58
|
.option('--capacity-gb <gb>', 'donated capacity in GB', '10')
|
|
59
|
+
.option('--auto-port-start <port>', 'start of auto-port range', '5001')
|
|
60
|
+
.option('--auto-port-end <port>', 'end of auto-port range', '5999')
|
|
19
61
|
.option('--heartbeat-ms <ms>', 'heartbeat interval in milliseconds', '30000');
|
|
20
62
|
|
|
21
|
-
program.action(async (options) => {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
63
|
+
program.action(async (capacityArg, options) => {
|
|
64
|
+
const autoPortStart = Number(options.autoPortStart);
|
|
65
|
+
const autoPortEnd = Number(options.autoPortEnd);
|
|
66
|
+
const port = options.port
|
|
67
|
+
? Number(options.port)
|
|
68
|
+
: await findFreePort(autoPortStart, autoPortEnd);
|
|
69
|
+
|
|
70
|
+
const parsedCapacityArg = parseCapacityGb(capacityArg);
|
|
71
|
+
const capacityGb = parsedCapacityArg ?? Number(options.capacityGb);
|
|
24
72
|
const heartbeatMs = Number(options.heartbeatMs);
|
|
73
|
+
const storageDir = options.storageDir
|
|
74
|
+
? path.resolve(options.storageDir)
|
|
75
|
+
: path.join(os.homedir(), '.nodio-nodes', `node-${port}`);
|
|
25
76
|
|
|
26
77
|
if (!Number.isInteger(port) || port <= 0) {
|
|
27
78
|
throw new Error('port must be a positive integer');
|
|
28
79
|
}
|
|
80
|
+
if (!Number.isInteger(autoPortStart) || !Number.isInteger(autoPortEnd) || autoPortStart <= 0 || autoPortEnd < autoPortStart) {
|
|
81
|
+
throw new Error('auto-port range is invalid');
|
|
82
|
+
}
|
|
29
83
|
if (!Number.isFinite(capacityGb) || capacityGb <= 0) {
|
|
30
84
|
throw new Error('capacity-gb must be greater than 0');
|
|
31
85
|
}
|
|
@@ -38,7 +92,7 @@ program.action(async (options) => {
|
|
|
38
92
|
serverUrl: options.server,
|
|
39
93
|
publicUrl: `http://${options.host}:${port}`,
|
|
40
94
|
port,
|
|
41
|
-
storageDir
|
|
95
|
+
storageDir,
|
|
42
96
|
capacityBytes: Math.floor(capacityGb * 1024 * 1024 * 1024),
|
|
43
97
|
heartbeatIntervalMs: heartbeatMs
|
|
44
98
|
});
|
package/src/node/runtime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const axios = require('axios');
|
|
3
3
|
const { LocalShardStore } = require('./storage');
|
|
4
|
+
const { getOrCreateDeviceKey } = require('./deviceIdentity');
|
|
4
5
|
|
|
5
6
|
function normalizeUrl(url) {
|
|
6
7
|
return String(url || '').replace(/\/+$/, '');
|
|
@@ -8,7 +9,7 @@ function normalizeUrl(url) {
|
|
|
8
9
|
|
|
9
10
|
class NodioNodeRuntime {
|
|
10
11
|
constructor(options) {
|
|
11
|
-
this.nodeId = options.nodeId;
|
|
12
|
+
this.nodeId = options.nodeId || null;
|
|
12
13
|
this.serverUrl = normalizeUrl(options.serverUrl);
|
|
13
14
|
this.publicUrl = normalizeUrl(options.publicUrl);
|
|
14
15
|
this.port = Number(options.port);
|
|
@@ -20,6 +21,11 @@ class NodioNodeRuntime {
|
|
|
20
21
|
|
|
21
22
|
async start() {
|
|
22
23
|
await this.shardStore.init();
|
|
24
|
+
|
|
25
|
+
if (!this.nodeId) {
|
|
26
|
+
this.nodeId = await this.shardStore.getSavedNodeId();
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
await this.startShardServer();
|
|
24
30
|
await this.registerNode();
|
|
25
31
|
await this.sendHeartbeat();
|
|
@@ -65,6 +71,16 @@ class NodioNodeRuntime {
|
|
|
65
71
|
}
|
|
66
72
|
});
|
|
67
73
|
|
|
74
|
+
app.delete('/shards/:shardId', async (req, res) => {
|
|
75
|
+
try {
|
|
76
|
+
const { shardId } = req.params;
|
|
77
|
+
const result = await this.shardStore.deleteShard(shardId);
|
|
78
|
+
res.json({ ok: true, shard: result });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
res.status(500).json({ error: error.message });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
68
84
|
app.get('/health', (_req, res) => {
|
|
69
85
|
res.json({ ok: true, nodeId: this.nodeId });
|
|
70
86
|
});
|
|
@@ -83,13 +99,21 @@ class NodioNodeRuntime {
|
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
async registerNode() {
|
|
102
|
+
const deviceKey = await getOrCreateDeviceKey();
|
|
103
|
+
const nodeKey = await this.shardStore.getOrCreateNodeKey();
|
|
104
|
+
|
|
86
105
|
const response = await axios.post(`${this.serverUrl}/api/nodes/register`, {
|
|
87
106
|
nodeId: this.nodeId,
|
|
107
|
+
deviceKey,
|
|
108
|
+
nodeKey,
|
|
88
109
|
url: this.publicUrl,
|
|
89
110
|
capacityBytes: this.capacityBytes,
|
|
90
111
|
freeBytes: await this.freeBytes()
|
|
91
112
|
});
|
|
92
113
|
|
|
114
|
+
this.nodeId = response.data.nodeId;
|
|
115
|
+
await this.shardStore.saveAssignedNodeId(this.nodeId);
|
|
116
|
+
|
|
93
117
|
const interval = Number(response.data.heartbeatIntervalMs);
|
|
94
118
|
if (Number.isFinite(interval) && interval > 0) {
|
|
95
119
|
this.heartbeatIntervalMs = interval;
|
package/src/node/storage.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('fs/promises');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const { sha256Hex } = require('../common/crypto');
|
|
4
5
|
|
|
5
6
|
class LocalShardStore {
|
|
@@ -7,6 +8,7 @@ class LocalShardStore {
|
|
|
7
8
|
this.baseDir = baseDir;
|
|
8
9
|
this.indexFile = path.join(baseDir, 'index.json');
|
|
9
10
|
this.shardsDir = path.join(baseDir, 'shards');
|
|
11
|
+
this.identityFile = path.join(baseDir, 'node-identity.json');
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
async init() {
|
|
@@ -31,6 +33,53 @@ class LocalShardStore {
|
|
|
31
33
|
await fs.writeFile(this.indexFile, JSON.stringify(index, null, 2), 'utf-8');
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
async readIdentity() {
|
|
37
|
+
try {
|
|
38
|
+
const raw = await fs.readFile(this.identityFile, 'utf-8');
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
41
|
+
} catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async writeIdentity(identity) {
|
|
47
|
+
await fs.writeFile(this.identityFile, JSON.stringify(identity, null, 2), 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async getIdentity() {
|
|
51
|
+
return this.readIdentity();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async getOrCreateNodeKey() {
|
|
55
|
+
const identity = await this.readIdentity();
|
|
56
|
+
if (identity.nodeKey) {
|
|
57
|
+
return identity.nodeKey;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const nodeKey = crypto.randomUUID();
|
|
61
|
+
await this.writeIdentity({ ...identity, nodeKey, updatedAt: new Date().toISOString() });
|
|
62
|
+
return nodeKey;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getSavedNodeId() {
|
|
66
|
+
const identity = await this.readIdentity();
|
|
67
|
+
return identity.nodeId || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async saveAssignedNodeId(nodeId) {
|
|
71
|
+
if (!nodeId) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const identity = await this.readIdentity();
|
|
76
|
+
await this.writeIdentity({
|
|
77
|
+
...identity,
|
|
78
|
+
nodeId,
|
|
79
|
+
updatedAt: new Date().toISOString()
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
34
83
|
shardPath(shardId) {
|
|
35
84
|
return path.join(this.shardsDir, `${shardId}.bin`);
|
|
36
85
|
}
|
|
@@ -64,6 +113,26 @@ class LocalShardStore {
|
|
|
64
113
|
}
|
|
65
114
|
}
|
|
66
115
|
|
|
116
|
+
async deleteShard(shardId) {
|
|
117
|
+
const filePath = this.shardPath(shardId);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await fs.unlink(filePath);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error.code !== 'ENOENT') {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const index = await this.readIndex();
|
|
128
|
+
if (index.shards[shardId]) {
|
|
129
|
+
delete index.shards[shardId];
|
|
130
|
+
await this.writeIndex(index);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { shardId, deleted: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
67
136
|
async listShardIds() {
|
|
68
137
|
const index = await this.readIndex();
|
|
69
138
|
return Object.keys(index.shards);
|
package/src/server/models.js
CHANGED
|
@@ -3,6 +3,8 @@ const mongoose = require('mongoose');
|
|
|
3
3
|
const nodeSchema = new mongoose.Schema(
|
|
4
4
|
{
|
|
5
5
|
nodeId: { type: String, required: true, unique: true, index: true },
|
|
6
|
+
deviceKey: { type: String, index: true },
|
|
7
|
+
nodeKey: { type: String, unique: true, sparse: true, index: true },
|
|
6
8
|
url: { type: String, required: true },
|
|
7
9
|
capacityBytes: { type: Number, required: true, min: 1 },
|
|
8
10
|
freeBytes: { type: Number, required: true, min: 0 },
|
package/src/server/routes.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
|
+
const axios = require('axios');
|
|
2
3
|
const { v4: uuidv4 } = require('uuid');
|
|
3
4
|
const {
|
|
4
5
|
NodeModel,
|
|
@@ -17,6 +18,10 @@ function parsePositiveInt(value, fallback) {
|
|
|
17
18
|
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function normalizeUrl(url) {
|
|
22
|
+
return String(url || '').replace(/\/+$/, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
function buildRoutes(config) {
|
|
21
26
|
const router = express.Router();
|
|
22
27
|
|
|
@@ -26,10 +31,10 @@ function buildRoutes(config) {
|
|
|
26
31
|
|
|
27
32
|
router.post('/nodes/register', async (req, res, next) => {
|
|
28
33
|
try {
|
|
29
|
-
const { nodeId, url, capacityBytes, freeBytes } = req.body;
|
|
34
|
+
const { nodeId, deviceKey, nodeKey, url, capacityBytes, freeBytes } = req.body;
|
|
30
35
|
|
|
31
|
-
if (!
|
|
32
|
-
return res.status(400).json({ error: '
|
|
36
|
+
if (!url) {
|
|
37
|
+
return res.status(400).json({ error: 'url is required' });
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
const capacity = Number(capacityBytes);
|
|
@@ -37,11 +42,28 @@ function buildRoutes(config) {
|
|
|
37
42
|
if (!Number.isFinite(capacity) || capacity <= 0 || !Number.isFinite(free) || free < 0) {
|
|
38
43
|
return res.status(400).json({ error: 'capacityBytes and freeBytes must be valid numbers' });
|
|
39
44
|
}
|
|
45
|
+
if (deviceKey && typeof deviceKey !== 'string') {
|
|
46
|
+
return res.status(400).json({ error: 'deviceKey must be a string when provided' });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let existingByNodeKey = null;
|
|
50
|
+
if (nodeKey) {
|
|
51
|
+
existingByNodeKey = await NodeModel.findOne({ nodeKey }).lean();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (existingByNodeKey && nodeId && nodeId !== existingByNodeKey.nodeId) {
|
|
55
|
+
return res.status(409).json({ error: 'nodeKey is already associated with a different nodeId' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const effectiveNodeId = nodeId || existingByNodeKey?.nodeId || `donor-${uuidv4().slice(0, 8)}`;
|
|
40
59
|
|
|
41
60
|
const node = await NodeModel.findOneAndUpdate(
|
|
42
|
-
{ nodeId },
|
|
61
|
+
{ nodeId: effectiveNodeId },
|
|
43
62
|
{
|
|
44
63
|
$set: {
|
|
64
|
+
nodeId: effectiveNodeId,
|
|
65
|
+
...(deviceKey ? { deviceKey } : {}),
|
|
66
|
+
...(nodeKey ? { nodeKey } : {}),
|
|
45
67
|
url,
|
|
46
68
|
capacityBytes: capacity,
|
|
47
69
|
freeBytes: free,
|
|
@@ -84,29 +106,34 @@ function buildRoutes(config) {
|
|
|
84
106
|
await node.save();
|
|
85
107
|
|
|
86
108
|
if (Array.isArray(shardIds) && shardIds.length > 0) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
.
|
|
90
|
-
|
|
109
|
+
try {
|
|
110
|
+
const normalizedShardIds = [...new Set(shardIds.filter(Boolean))];
|
|
111
|
+
const knownShards = await ShardModel.find({ shardId: { $in: normalizedShardIds } })
|
|
112
|
+
.select('shardId fileId')
|
|
113
|
+
.lean();
|
|
114
|
+
|
|
115
|
+
for (const shard of knownShards) {
|
|
116
|
+
if (!shard.fileId) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
91
119
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
await ShardPlacementModel.updateOne(
|
|
121
|
+
{ shardId: shard.shardId, nodeId },
|
|
122
|
+
{
|
|
123
|
+
$set: {
|
|
124
|
+
status: 'available',
|
|
125
|
+
fileId: shard.fileId
|
|
126
|
+
},
|
|
127
|
+
$setOnInsert: {
|
|
128
|
+
shardId: shard.shardId,
|
|
129
|
+
nodeId
|
|
130
|
+
}
|
|
100
131
|
},
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
{ upsert: true }
|
|
109
|
-
);
|
|
132
|
+
{ upsert: true }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
} catch (syncError) {
|
|
136
|
+
console.warn('[heartbeat-sync]', nodeId, syncError.message);
|
|
110
137
|
}
|
|
111
138
|
}
|
|
112
139
|
|
|
@@ -313,6 +340,75 @@ function buildRoutes(config) {
|
|
|
313
340
|
}
|
|
314
341
|
});
|
|
315
342
|
|
|
343
|
+
router.delete('/files/:fileId', async (req, res, next) => {
|
|
344
|
+
try {
|
|
345
|
+
const { fileId } = req.params;
|
|
346
|
+
const file = await FileModel.findOne({ fileId }).lean();
|
|
347
|
+
if (!file) {
|
|
348
|
+
return res.status(404).json({ error: 'file not found' });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const shards = await ShardModel.find({ fileId }).select('shardId').lean();
|
|
352
|
+
const shardIds = shards.map((shard) => shard.shardId);
|
|
353
|
+
|
|
354
|
+
const placements = await ShardPlacementModel.find({ fileId }).lean();
|
|
355
|
+
const nodeIds = [...new Set(placements.map((placement) => placement.nodeId))];
|
|
356
|
+
|
|
357
|
+
const onlineNodes = await NodeModel.find({ nodeId: { $in: nodeIds }, status: 'online' })
|
|
358
|
+
.select('nodeId url')
|
|
359
|
+
.lean();
|
|
360
|
+
const nodeUrlMap = new Map(onlineNodes.map((node) => [node.nodeId, node.url]));
|
|
361
|
+
|
|
362
|
+
const shardDeleteFailures = [];
|
|
363
|
+
let deleteAttempts = 0;
|
|
364
|
+
let deleteSuccesses = 0;
|
|
365
|
+
let deleteSkippedOffline = 0;
|
|
366
|
+
for (const placement of placements) {
|
|
367
|
+
const nodeUrl = nodeUrlMap.get(placement.nodeId);
|
|
368
|
+
if (!nodeUrl) {
|
|
369
|
+
deleteSkippedOffline += 1;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const deleteUrl = `${normalizeUrl(nodeUrl)}/shards/${placement.shardId}`;
|
|
374
|
+
deleteAttempts += 1;
|
|
375
|
+
try {
|
|
376
|
+
await axios.delete(deleteUrl, { timeout: 15000 });
|
|
377
|
+
deleteSuccesses += 1;
|
|
378
|
+
} catch (error) {
|
|
379
|
+
shardDeleteFailures.push({
|
|
380
|
+
shardId: placement.shardId,
|
|
381
|
+
nodeId: placement.nodeId,
|
|
382
|
+
error: error.response?.data?.error || error.message
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
await ReplicationTaskModel.deleteMany({
|
|
388
|
+
$or: [
|
|
389
|
+
{ fileId },
|
|
390
|
+
{ shardId: { $in: shardIds } }
|
|
391
|
+
]
|
|
392
|
+
});
|
|
393
|
+
await ShardPlacementModel.deleteMany({ fileId });
|
|
394
|
+
await ShardModel.deleteMany({ fileId });
|
|
395
|
+
await FileModel.deleteOne({ fileId });
|
|
396
|
+
|
|
397
|
+
res.json({
|
|
398
|
+
ok: true,
|
|
399
|
+
fileId,
|
|
400
|
+
deletedShards: shardIds.length,
|
|
401
|
+
deletedPlacements: placements.length,
|
|
402
|
+
donorDeleteAttempts: deleteAttempts,
|
|
403
|
+
donorDeleteSuccesses: deleteSuccesses,
|
|
404
|
+
donorDeleteSkippedOffline: deleteSkippedOffline,
|
|
405
|
+
shardDeleteFailures
|
|
406
|
+
});
|
|
407
|
+
} catch (error) {
|
|
408
|
+
next(error);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
316
412
|
router.get('/files/:fileId/manifest', async (req, res, next) => {
|
|
317
413
|
try {
|
|
318
414
|
const { fileId } = req.params;
|
package/src/user/commands.js
CHANGED
|
@@ -199,7 +199,26 @@ async function downloadFile(options) {
|
|
|
199
199
|
console.log(`sizeBytes: ${reconstructed.length}`);
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
async function deleteFile(options) {
|
|
203
|
+
const fileId = options.fileId;
|
|
204
|
+
const serverUrl = options.server;
|
|
205
|
+
|
|
206
|
+
const api = createApiClient(serverUrl);
|
|
207
|
+
const response = await api.delete(`/files/${fileId}`);
|
|
208
|
+
const payload = response.data || {};
|
|
209
|
+
|
|
210
|
+
console.log('Delete complete');
|
|
211
|
+
console.log(`fileId: ${payload.fileId || fileId}`);
|
|
212
|
+
console.log(`deletedShards: ${payload.deletedShards || 0}`);
|
|
213
|
+
console.log(`deletedPlacementsMetadata: ${payload.deletedPlacements || 0}`);
|
|
214
|
+
console.log(`donorDeleteAttempts: ${payload.donorDeleteAttempts || 0}`);
|
|
215
|
+
console.log(`donorDeleteSuccesses: ${payload.donorDeleteSuccesses || 0}`);
|
|
216
|
+
console.log(`donorDeleteSkippedOffline: ${payload.donorDeleteSkippedOffline || 0}`);
|
|
217
|
+
console.log(`shardDeleteFailures: ${(payload.shardDeleteFailures || []).length}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
202
220
|
module.exports = {
|
|
203
221
|
uploadFile,
|
|
204
|
-
downloadFile
|
|
222
|
+
downloadFile,
|
|
223
|
+
deleteFile
|
|
205
224
|
};
|
package/src/user/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const { Command } = require('commander');
|
|
3
|
-
const { uploadFile, downloadFile } = require('./commands');
|
|
3
|
+
const { uploadFile, downloadFile, deleteFile } = require('./commands');
|
|
4
4
|
|
|
5
5
|
const program = new Command();
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ program
|
|
|
10
10
|
.command('upload')
|
|
11
11
|
.description('Encrypt, shard, and distribute a file across donor nodes')
|
|
12
12
|
.requiredOption('--file <path>', 'path to local file')
|
|
13
|
-
.option('--server <url>', 'central server URL', '
|
|
13
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
14
14
|
.option('--file-id <id>', 'custom file ID (optional)')
|
|
15
15
|
.option('--shard-size-mb <mb>', 'plaintext shard size in MB', '1')
|
|
16
16
|
.option('--replicas <count>', 'replicas per shard (minimum 5)', '5')
|
|
@@ -24,12 +24,21 @@ program
|
|
|
24
24
|
.description('Download, verify, decrypt, and reconstruct a file')
|
|
25
25
|
.requiredOption('--file-id <id>', 'file ID to download')
|
|
26
26
|
.requiredOption('--key-base64 <key>', '32-byte AES key in base64 from upload output')
|
|
27
|
-
.option('--server <url>', 'central server URL', '
|
|
27
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
28
28
|
.option('--output <path>', 'output file path')
|
|
29
29
|
.action(async (options) => {
|
|
30
30
|
await downloadFile(options);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
program
|
|
34
|
+
.command('delete')
|
|
35
|
+
.description('Delete a file and its shard replicas from the network')
|
|
36
|
+
.requiredOption('--file-id <id>', 'file ID to delete')
|
|
37
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
38
|
+
.action(async (options) => {
|
|
39
|
+
await deleteFile(options);
|
|
40
|
+
});
|
|
41
|
+
|
|
33
42
|
program.parseAsync(process.argv).catch((error) => {
|
|
34
43
|
console.error('[nodio]', error.message);
|
|
35
44
|
process.exit(1);
|