nodio-cli 1.0.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 +6 -0
- package/README.md +127 -0
- package/package.json +33 -0
- package/render.yaml +22 -0
- package/src/common/crypto.js +41 -0
- package/src/node/index.js +52 -0
- package/src/node/runtime.js +154 -0
- package/src/node/storage.js +80 -0
- package/src/server/config.js +17 -0
- package/src/server/index.js +58 -0
- package/src/server/models.js +84 -0
- package/src/server/routes.js +372 -0
- package/src/server/services.js +109 -0
- package/src/user/client.js +20 -0
- package/src/user/commands.js +205 -0
- package/src/user/index.js +36 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const { v4: uuidv4 } = require('uuid');
|
|
3
|
+
const {
|
|
4
|
+
NodeModel,
|
|
5
|
+
FileModel,
|
|
6
|
+
ShardModel,
|
|
7
|
+
ShardPlacementModel,
|
|
8
|
+
ReplicationTaskModel
|
|
9
|
+
} = require('./models');
|
|
10
|
+
const {
|
|
11
|
+
chooseDistinctOnlineNodes,
|
|
12
|
+
ensureEmergencyReplicasForShard
|
|
13
|
+
} = require('./services');
|
|
14
|
+
|
|
15
|
+
function parsePositiveInt(value, fallback) {
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildRoutes(config) {
|
|
21
|
+
const router = express.Router();
|
|
22
|
+
|
|
23
|
+
router.get('/health', async (_req, res) => {
|
|
24
|
+
res.json({ ok: true, service: 'nodio-server' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
router.post('/nodes/register', async (req, res, next) => {
|
|
28
|
+
try {
|
|
29
|
+
const { nodeId, url, capacityBytes, freeBytes } = req.body;
|
|
30
|
+
|
|
31
|
+
if (!nodeId || !url) {
|
|
32
|
+
return res.status(400).json({ error: 'nodeId and url are required' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const capacity = Number(capacityBytes);
|
|
36
|
+
const free = Number(freeBytes);
|
|
37
|
+
if (!Number.isFinite(capacity) || capacity <= 0 || !Number.isFinite(free) || free < 0) {
|
|
38
|
+
return res.status(400).json({ error: 'capacityBytes and freeBytes must be valid numbers' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const node = await NodeModel.findOneAndUpdate(
|
|
42
|
+
{ nodeId },
|
|
43
|
+
{
|
|
44
|
+
$set: {
|
|
45
|
+
url,
|
|
46
|
+
capacityBytes: capacity,
|
|
47
|
+
freeBytes: free,
|
|
48
|
+
status: 'online',
|
|
49
|
+
lastHeartbeatAt: new Date()
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
res.json({
|
|
56
|
+
nodeId: node.nodeId,
|
|
57
|
+
status: node.status,
|
|
58
|
+
heartbeatIntervalMs: config.heartbeatIntervalMs,
|
|
59
|
+
minReplicas: config.minReplicas,
|
|
60
|
+
emergencyReplicaFloor: config.emergencyReplicaFloor
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
next(error);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
router.post('/nodes/heartbeat', async (req, res, next) => {
|
|
68
|
+
try {
|
|
69
|
+
const { nodeId, freeBytes, shardIds } = req.body;
|
|
70
|
+
if (!nodeId) {
|
|
71
|
+
return res.status(400).json({ error: 'nodeId is required' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const node = await NodeModel.findOne({ nodeId });
|
|
75
|
+
if (!node) {
|
|
76
|
+
return res.status(404).json({ error: 'node not found' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
node.status = 'online';
|
|
80
|
+
node.lastHeartbeatAt = new Date();
|
|
81
|
+
if (Number.isFinite(Number(freeBytes)) && Number(freeBytes) >= 0) {
|
|
82
|
+
node.freeBytes = Number(freeBytes);
|
|
83
|
+
}
|
|
84
|
+
await node.save();
|
|
85
|
+
|
|
86
|
+
if (Array.isArray(shardIds) && shardIds.length > 0) {
|
|
87
|
+
const normalizedShardIds = [...new Set(shardIds.filter(Boolean))];
|
|
88
|
+
const knownShards = await ShardModel.find({ shardId: { $in: normalizedShardIds } })
|
|
89
|
+
.select('shardId fileId')
|
|
90
|
+
.lean();
|
|
91
|
+
|
|
92
|
+
for (const shard of knownShards) {
|
|
93
|
+
await ShardPlacementModel.updateOne(
|
|
94
|
+
{ shardId: shard.shardId, nodeId },
|
|
95
|
+
{
|
|
96
|
+
$set: {
|
|
97
|
+
status: 'available',
|
|
98
|
+
fileId: shard.fileId,
|
|
99
|
+
updatedAt: new Date()
|
|
100
|
+
},
|
|
101
|
+
$setOnInsert: {
|
|
102
|
+
shardId: shard.shardId,
|
|
103
|
+
nodeId,
|
|
104
|
+
fileId: shard.fileId,
|
|
105
|
+
createdAt: new Date()
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{ upsert: true }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const pendingTasks = await ReplicationTaskModel.find({
|
|
114
|
+
targetNodeId: nodeId,
|
|
115
|
+
status: 'pending'
|
|
116
|
+
})
|
|
117
|
+
.sort({ createdAt: 1 })
|
|
118
|
+
.limit(5)
|
|
119
|
+
.lean();
|
|
120
|
+
|
|
121
|
+
if (pendingTasks.length > 0) {
|
|
122
|
+
await ReplicationTaskModel.updateMany(
|
|
123
|
+
{ _id: { $in: pendingTasks.map((task) => task._id) } },
|
|
124
|
+
{ $set: { status: 'in_progress' }, $inc: { attempts: 1 } }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const tasksWithSourceUrl = [];
|
|
129
|
+
for (const task of pendingTasks) {
|
|
130
|
+
const source = await NodeModel.findOne({ nodeId: task.sourceNodeId, status: 'online' })
|
|
131
|
+
.select('url nodeId')
|
|
132
|
+
.lean();
|
|
133
|
+
|
|
134
|
+
if (!source) {
|
|
135
|
+
await ReplicationTaskModel.updateOne(
|
|
136
|
+
{ _id: task._id },
|
|
137
|
+
{
|
|
138
|
+
$set: {
|
|
139
|
+
status: 'failed',
|
|
140
|
+
errorMessage: 'source node is offline'
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
tasksWithSourceUrl.push({
|
|
148
|
+
taskId: task._id.toString(),
|
|
149
|
+
shardId: task.shardId,
|
|
150
|
+
fileId: task.fileId,
|
|
151
|
+
sourceNodeId: source.nodeId,
|
|
152
|
+
sourceUrl: source.url
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
res.json({
|
|
157
|
+
ok: true,
|
|
158
|
+
now: new Date().toISOString(),
|
|
159
|
+
replicationTasks: tasksWithSourceUrl
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
next(error);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
router.post('/replication-tasks/:taskId/complete', async (req, res, next) => {
|
|
167
|
+
try {
|
|
168
|
+
const { taskId } = req.params;
|
|
169
|
+
const { nodeId, success, errorMessage } = req.body;
|
|
170
|
+
|
|
171
|
+
const task = await ReplicationTaskModel.findById(taskId);
|
|
172
|
+
if (!task) {
|
|
173
|
+
return res.status(404).json({ error: 'replication task not found' });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (task.targetNodeId !== nodeId) {
|
|
177
|
+
return res.status(403).json({ error: 'nodeId does not match task target' });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (success) {
|
|
181
|
+
task.status = 'completed';
|
|
182
|
+
task.errorMessage = null;
|
|
183
|
+
await task.save();
|
|
184
|
+
|
|
185
|
+
await ShardPlacementModel.updateOne(
|
|
186
|
+
{ shardId: task.shardId, nodeId: task.targetNodeId },
|
|
187
|
+
{
|
|
188
|
+
$set: {
|
|
189
|
+
fileId: task.fileId,
|
|
190
|
+
status: 'available'
|
|
191
|
+
},
|
|
192
|
+
$setOnInsert: {
|
|
193
|
+
shardId: task.shardId,
|
|
194
|
+
nodeId: task.targetNodeId
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
{ upsert: true }
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
await ensureEmergencyReplicasForShard(task.shardId, config.emergencyReplicaFloor);
|
|
201
|
+
} else {
|
|
202
|
+
task.status = 'failed';
|
|
203
|
+
task.errorMessage = errorMessage || 'replication failed';
|
|
204
|
+
await task.save();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
res.json({ ok: true });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
next(error);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
router.post('/files/register', async (req, res, next) => {
|
|
214
|
+
try {
|
|
215
|
+
const { fileId, originalName, sizeBytes, shardCount, cipher, metadata } = req.body;
|
|
216
|
+
if (!originalName || !Number.isFinite(Number(sizeBytes)) || Number(sizeBytes) < 0) {
|
|
217
|
+
return res.status(400).json({ error: 'originalName and sizeBytes are required' });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const normalizedShardCount = parsePositiveInt(shardCount, 1);
|
|
221
|
+
const actualFileId = fileId || uuidv4();
|
|
222
|
+
|
|
223
|
+
const file = await FileModel.findOneAndUpdate(
|
|
224
|
+
{ fileId: actualFileId },
|
|
225
|
+
{
|
|
226
|
+
$set: {
|
|
227
|
+
fileId: actualFileId,
|
|
228
|
+
originalName,
|
|
229
|
+
sizeBytes: Number(sizeBytes),
|
|
230
|
+
shardCount: normalizedShardCount,
|
|
231
|
+
cipher: cipher || 'aes-256-gcm',
|
|
232
|
+
metadata: metadata || {}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
res.json({ fileId: file.fileId, shardCount: file.shardCount });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
next(error);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
router.post('/shards/register', async (req, res, next) => {
|
|
245
|
+
try {
|
|
246
|
+
const { shardId, fileId, order, sizeBytes, checksum, nodeIds } = req.body;
|
|
247
|
+
if (!shardId || !fileId || !checksum || !Array.isArray(nodeIds) || nodeIds.length === 0) {
|
|
248
|
+
return res.status(400).json({ error: 'shardId, fileId, checksum and nodeIds are required' });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await ShardModel.findOneAndUpdate(
|
|
252
|
+
{ shardId },
|
|
253
|
+
{
|
|
254
|
+
$set: {
|
|
255
|
+
shardId,
|
|
256
|
+
fileId,
|
|
257
|
+
order: Number(order) || 0,
|
|
258
|
+
sizeBytes: Number(sizeBytes) || 0,
|
|
259
|
+
checksum
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const uniqueNodeIds = [...new Set(nodeIds)];
|
|
266
|
+
for (const nodeId of uniqueNodeIds) {
|
|
267
|
+
await ShardPlacementModel.updateOne(
|
|
268
|
+
{ shardId, nodeId },
|
|
269
|
+
{
|
|
270
|
+
$set: {
|
|
271
|
+
fileId,
|
|
272
|
+
status: 'available'
|
|
273
|
+
},
|
|
274
|
+
$setOnInsert: {
|
|
275
|
+
shardId,
|
|
276
|
+
nodeId
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
{ upsert: true }
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await ensureEmergencyReplicasForShard(shardId, config.emergencyReplicaFloor);
|
|
284
|
+
|
|
285
|
+
res.json({ ok: true });
|
|
286
|
+
} catch (error) {
|
|
287
|
+
next(error);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
router.post('/shards/placement-plan', async (req, res, next) => {
|
|
292
|
+
try {
|
|
293
|
+
const { shardId, sizeBytes, replicas } = req.body;
|
|
294
|
+
if (!shardId || !Number.isFinite(Number(sizeBytes)) || Number(sizeBytes) < 0) {
|
|
295
|
+
return res.status(400).json({ error: 'shardId and sizeBytes are required' });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const replicaCount = parsePositiveInt(replicas, config.minReplicas);
|
|
299
|
+
const nodes = await chooseDistinctOnlineNodes(replicaCount, Number(sizeBytes));
|
|
300
|
+
|
|
301
|
+
res.json({
|
|
302
|
+
shardId,
|
|
303
|
+
replicas: nodes.map((node) => ({
|
|
304
|
+
nodeId: node.nodeId,
|
|
305
|
+
url: node.url
|
|
306
|
+
}))
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (String(error.message).startsWith('insufficient_online_nodes')) {
|
|
310
|
+
return res.status(409).json({ error: error.message });
|
|
311
|
+
}
|
|
312
|
+
next(error);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
router.get('/files/:fileId/manifest', async (req, res, next) => {
|
|
317
|
+
try {
|
|
318
|
+
const { fileId } = req.params;
|
|
319
|
+
const file = await FileModel.findOne({ fileId }).lean();
|
|
320
|
+
if (!file) {
|
|
321
|
+
return res.status(404).json({ error: 'file not found' });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const shards = await ShardModel.find({ fileId }).sort({ order: 1 }).lean();
|
|
325
|
+
const placements = await ShardPlacementModel.find({ fileId, status: 'available' }).lean();
|
|
326
|
+
|
|
327
|
+
const nodeIds = [...new Set(placements.map((placement) => placement.nodeId))];
|
|
328
|
+
const nodes = await NodeModel.find({ nodeId: { $in: nodeIds }, status: 'online' })
|
|
329
|
+
.select('nodeId url')
|
|
330
|
+
.lean();
|
|
331
|
+
const nodeMap = new Map(nodes.map((node) => [node.nodeId, node.url]));
|
|
332
|
+
|
|
333
|
+
const shardManifests = shards.map((shard) => {
|
|
334
|
+
const shardPlacements = placements
|
|
335
|
+
.filter((placement) => placement.shardId === shard.shardId)
|
|
336
|
+
.map((placement) => ({
|
|
337
|
+
nodeId: placement.nodeId,
|
|
338
|
+
url: nodeMap.get(placement.nodeId)
|
|
339
|
+
}))
|
|
340
|
+
.filter((placement) => Boolean(placement.url));
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
shardId: shard.shardId,
|
|
344
|
+
order: shard.order,
|
|
345
|
+
sizeBytes: shard.sizeBytes,
|
|
346
|
+
checksum: shard.checksum,
|
|
347
|
+
replicas: shardPlacements
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
res.json({
|
|
352
|
+
file: {
|
|
353
|
+
fileId: file.fileId,
|
|
354
|
+
originalName: file.originalName,
|
|
355
|
+
sizeBytes: file.sizeBytes,
|
|
356
|
+
shardCount: file.shardCount,
|
|
357
|
+
cipher: file.cipher,
|
|
358
|
+
metadata: file.metadata
|
|
359
|
+
},
|
|
360
|
+
shards: shardManifests
|
|
361
|
+
});
|
|
362
|
+
} catch (error) {
|
|
363
|
+
next(error);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
return router;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = {
|
|
371
|
+
buildRoutes
|
|
372
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const {
|
|
2
|
+
NodeModel,
|
|
3
|
+
ShardModel,
|
|
4
|
+
ShardPlacementModel,
|
|
5
|
+
ReplicationTaskModel
|
|
6
|
+
} = require('./models');
|
|
7
|
+
|
|
8
|
+
async function chooseDistinctOnlineNodes(requiredCount, minFreeBytes = 0, excludedNodeIds = []) {
|
|
9
|
+
const excluded = new Set(excludedNodeIds);
|
|
10
|
+
|
|
11
|
+
const candidates = await NodeModel.find({
|
|
12
|
+
status: 'online',
|
|
13
|
+
freeBytes: { $gte: minFreeBytes },
|
|
14
|
+
nodeId: { $nin: [...excluded] }
|
|
15
|
+
})
|
|
16
|
+
.sort({ freeBytes: -1, lastHeartbeatAt: -1 })
|
|
17
|
+
.lean();
|
|
18
|
+
|
|
19
|
+
if (candidates.length < requiredCount) {
|
|
20
|
+
throw new Error(`insufficient_online_nodes: required ${requiredCount}, found ${candidates.length}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return candidates.slice(0, requiredCount);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function ensureEmergencyReplicasForShard(shardId, emergencyReplicaFloor) {
|
|
27
|
+
const shard = await ShardModel.findOne({ shardId }).lean();
|
|
28
|
+
if (!shard) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const existingPlacements = await ShardPlacementModel.find({
|
|
33
|
+
shardId,
|
|
34
|
+
status: 'available'
|
|
35
|
+
}).lean();
|
|
36
|
+
|
|
37
|
+
const onlineMap = new Map();
|
|
38
|
+
const onlineNodes = await NodeModel.find({ status: 'online' }).lean();
|
|
39
|
+
for (const node of onlineNodes) {
|
|
40
|
+
onlineMap.set(node.nodeId, node);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const livePlacements = existingPlacements.filter((p) => onlineMap.has(p.nodeId));
|
|
44
|
+
const currentReplicaCount = livePlacements.length;
|
|
45
|
+
|
|
46
|
+
if (currentReplicaCount >= emergencyReplicaFloor) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sourcePlacement = livePlacements[0];
|
|
51
|
+
if (!sourcePlacement) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const missing = emergencyReplicaFloor - currentReplicaCount;
|
|
56
|
+
const excluded = livePlacements.map((p) => p.nodeId);
|
|
57
|
+
const targets = await chooseDistinctOnlineNodes(missing, shard.sizeBytes, excluded);
|
|
58
|
+
|
|
59
|
+
for (const target of targets) {
|
|
60
|
+
await ReplicationTaskModel.updateOne(
|
|
61
|
+
{
|
|
62
|
+
shardId,
|
|
63
|
+
targetNodeId: target.nodeId,
|
|
64
|
+
status: { $in: ['pending', 'in_progress'] }
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
$setOnInsert: {
|
|
68
|
+
shardId,
|
|
69
|
+
fileId: shard.fileId,
|
|
70
|
+
sourceNodeId: sourcePlacement.nodeId,
|
|
71
|
+
targetNodeId: target.nodeId,
|
|
72
|
+
status: 'pending',
|
|
73
|
+
attempts: 0
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{ upsert: true }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function markNodeOfflineAndRecover(nodeId, emergencyReplicaFloor) {
|
|
82
|
+
const node = await NodeModel.findOne({ nodeId });
|
|
83
|
+
if (!node || node.status === 'offline') {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
node.status = 'offline';
|
|
88
|
+
await node.save();
|
|
89
|
+
|
|
90
|
+
await ShardPlacementModel.updateMany(
|
|
91
|
+
{ nodeId, status: 'available' },
|
|
92
|
+
{ $set: { status: 'lost' } }
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const affectedShardRows = await ShardPlacementModel.find({ nodeId }).select('shardId').lean();
|
|
96
|
+
const shardIds = [...new Set(affectedShardRows.map((row) => row.shardId))];
|
|
97
|
+
|
|
98
|
+
for (const shardId of shardIds) {
|
|
99
|
+
await ensureEmergencyReplicasForShard(shardId, emergencyReplicaFloor);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
chooseDistinctOnlineNodes,
|
|
107
|
+
ensureEmergencyReplicasForShard,
|
|
108
|
+
markNodeOfflineAndRecover
|
|
109
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
|
|
3
|
+
function normalizeUrl(url) {
|
|
4
|
+
return String(url || '').replace(/\/+$/, '');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function createApiClient(serverUrl) {
|
|
8
|
+
return axios.create({
|
|
9
|
+
baseURL: `${normalizeUrl(serverUrl)}/api`,
|
|
10
|
+
timeout: 30000,
|
|
11
|
+
headers: {
|
|
12
|
+
'Content-Type': 'application/json'
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
createApiClient,
|
|
19
|
+
normalizeUrl
|
|
20
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const axios = require('axios');
|
|
5
|
+
const { v4: uuidv4 } = require('uuid');
|
|
6
|
+
const { createApiClient, normalizeUrl } = require('./client');
|
|
7
|
+
const { encryptAes256Gcm, decryptAes256Gcm, sha256Hex } = require('../common/crypto');
|
|
8
|
+
|
|
9
|
+
function splitBuffer(buffer, shardSizeBytes) {
|
|
10
|
+
if (shardSizeBytes <= 0) {
|
|
11
|
+
throw new Error('shard size must be greater than zero');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const chunks = [];
|
|
15
|
+
for (let offset = 0; offset < buffer.length; offset += shardSizeBytes) {
|
|
16
|
+
chunks.push(buffer.subarray(offset, Math.min(offset + shardSizeBytes, buffer.length)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return chunks.length > 0 ? chunks : [Buffer.alloc(0)];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseAesKey(keyBase64) {
|
|
23
|
+
const key = Buffer.from(keyBase64, 'base64');
|
|
24
|
+
if (key.length !== 32) {
|
|
25
|
+
throw new Error('key must decode to exactly 32 bytes for AES-256');
|
|
26
|
+
}
|
|
27
|
+
return key;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function uploadFile(options) {
|
|
31
|
+
const filePath = path.resolve(options.file);
|
|
32
|
+
const serverUrl = options.server;
|
|
33
|
+
const shardSizeMb = Number(options.shardSizeMb || 1);
|
|
34
|
+
const replicas = Number(options.replicas || 5);
|
|
35
|
+
|
|
36
|
+
if (!Number.isFinite(shardSizeMb) || shardSizeMb <= 0) {
|
|
37
|
+
throw new Error('shard-size-mb must be greater than 0');
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isInteger(replicas) || replicas < 5) {
|
|
40
|
+
throw new Error('replicas must be an integer >= 5');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const shardSizeBytes = Math.floor(shardSizeMb * 1024 * 1024);
|
|
44
|
+
const plainBuffer = await fs.readFile(filePath);
|
|
45
|
+
const chunks = splitBuffer(plainBuffer, shardSizeBytes);
|
|
46
|
+
|
|
47
|
+
const keyBuffer = options.keyBase64 ? parseAesKey(options.keyBase64) : crypto.randomBytes(32);
|
|
48
|
+
const keyBase64 = keyBuffer.toString('base64');
|
|
49
|
+
|
|
50
|
+
const api = createApiClient(serverUrl);
|
|
51
|
+
const fileId = options.fileId || uuidv4();
|
|
52
|
+
const originalName = path.basename(filePath);
|
|
53
|
+
|
|
54
|
+
await api.post('/files/register', {
|
|
55
|
+
fileId,
|
|
56
|
+
originalName,
|
|
57
|
+
sizeBytes: plainBuffer.length,
|
|
58
|
+
shardCount: chunks.length,
|
|
59
|
+
cipher: 'aes-256-gcm',
|
|
60
|
+
metadata: {
|
|
61
|
+
encryption: {
|
|
62
|
+
algorithm: 'aes-256-gcm',
|
|
63
|
+
shardSizeBytes,
|
|
64
|
+
keyFormat: 'base64',
|
|
65
|
+
shards: []
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const encryptionShardMeta = [];
|
|
71
|
+
|
|
72
|
+
for (let order = 0; order < chunks.length; order += 1) {
|
|
73
|
+
const shardId = `${fileId}-shard-${order}-${uuidv4().slice(0, 8)}`;
|
|
74
|
+
const encrypted = encryptAes256Gcm(chunks[order], keyBuffer);
|
|
75
|
+
const checksum = sha256Hex(encrypted.cipherText);
|
|
76
|
+
|
|
77
|
+
const placementResponse = await api.post('/shards/placement-plan', {
|
|
78
|
+
shardId,
|
|
79
|
+
sizeBytes: encrypted.cipherText.length,
|
|
80
|
+
replicas
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const plannedReplicas = placementResponse.data.replicas || [];
|
|
84
|
+
if (plannedReplicas.length < replicas) {
|
|
85
|
+
throw new Error(`placement failed for ${shardId}: expected ${replicas} replicas`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const replica of plannedReplicas) {
|
|
89
|
+
const putUrl = `${normalizeUrl(replica.url)}/shards/${shardId}`;
|
|
90
|
+
await axios.put(putUrl, encrypted.cipherText, {
|
|
91
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
92
|
+
timeout: 30000
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await api.post('/shards/register', {
|
|
97
|
+
shardId,
|
|
98
|
+
fileId,
|
|
99
|
+
order,
|
|
100
|
+
sizeBytes: encrypted.cipherText.length,
|
|
101
|
+
checksum,
|
|
102
|
+
nodeIds: plannedReplicas.map((replica) => replica.nodeId)
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
encryptionShardMeta.push({
|
|
106
|
+
shardId,
|
|
107
|
+
order,
|
|
108
|
+
iv: encrypted.iv,
|
|
109
|
+
authTag: encrypted.authTag
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await api.post('/files/register', {
|
|
114
|
+
fileId,
|
|
115
|
+
originalName,
|
|
116
|
+
sizeBytes: plainBuffer.length,
|
|
117
|
+
shardCount: chunks.length,
|
|
118
|
+
cipher: 'aes-256-gcm',
|
|
119
|
+
metadata: {
|
|
120
|
+
encryption: {
|
|
121
|
+
algorithm: 'aes-256-gcm',
|
|
122
|
+
shardSizeBytes,
|
|
123
|
+
keyFormat: 'base64',
|
|
124
|
+
shards: encryptionShardMeta
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
console.log(`Upload complete`);
|
|
130
|
+
console.log(`fileId: ${fileId}`);
|
|
131
|
+
console.log(`originalName: ${originalName}`);
|
|
132
|
+
console.log(`sizeBytes: ${plainBuffer.length}`);
|
|
133
|
+
console.log(`shardCount: ${chunks.length}`);
|
|
134
|
+
console.log(`replicasPerShard: ${replicas}`);
|
|
135
|
+
console.log(`aes256KeyBase64: ${keyBase64}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function downloadFile(options) {
|
|
139
|
+
const fileId = options.fileId;
|
|
140
|
+
const serverUrl = options.server;
|
|
141
|
+
const output = options.output ? path.resolve(options.output) : path.resolve(`./${fileId}.downloaded`);
|
|
142
|
+
const keyBuffer = parseAesKey(options.keyBase64);
|
|
143
|
+
|
|
144
|
+
const api = createApiClient(serverUrl);
|
|
145
|
+
const manifestResponse = await api.get(`/files/${fileId}/manifest`);
|
|
146
|
+
const manifest = manifestResponse.data;
|
|
147
|
+
const metadata = manifest.file?.metadata || {};
|
|
148
|
+
const encryption = metadata.encryption;
|
|
149
|
+
|
|
150
|
+
if (!encryption || encryption.algorithm !== 'aes-256-gcm') {
|
|
151
|
+
throw new Error('missing or unsupported encryption metadata');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const shardMetaMap = new Map((encryption.shards || []).map((entry) => [entry.shardId, entry]));
|
|
155
|
+
|
|
156
|
+
const orderedShards = [...(manifest.shards || [])].sort((a, b) => a.order - b.order);
|
|
157
|
+
const plainParts = [];
|
|
158
|
+
|
|
159
|
+
for (const shard of orderedShards) {
|
|
160
|
+
const shardMeta = shardMetaMap.get(shard.shardId);
|
|
161
|
+
if (!shardMeta) {
|
|
162
|
+
throw new Error(`missing encryption metadata for shard ${shard.shardId}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let encryptedBuffer = null;
|
|
166
|
+
for (const replica of shard.replicas || []) {
|
|
167
|
+
try {
|
|
168
|
+
const getUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
|
|
169
|
+
const response = await axios.get(getUrl, {
|
|
170
|
+
responseType: 'arraybuffer',
|
|
171
|
+
timeout: 30000
|
|
172
|
+
});
|
|
173
|
+
const candidate = Buffer.from(response.data);
|
|
174
|
+
if (sha256Hex(candidate) !== shard.checksum) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
encryptedBuffer = candidate;
|
|
178
|
+
break;
|
|
179
|
+
} catch {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!encryptedBuffer) {
|
|
185
|
+
throw new Error(`failed to fetch valid replica for shard ${shard.shardId}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const plain = decryptAes256Gcm(encryptedBuffer, keyBuffer, shardMeta.iv, shardMeta.authTag);
|
|
189
|
+
plainParts.push(plain);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const reconstructed = Buffer.concat(plainParts);
|
|
193
|
+
await fs.mkdir(path.dirname(output), { recursive: true });
|
|
194
|
+
await fs.writeFile(output, reconstructed);
|
|
195
|
+
|
|
196
|
+
console.log(`Download complete`);
|
|
197
|
+
console.log(`fileId: ${fileId}`);
|
|
198
|
+
console.log(`output: ${output}`);
|
|
199
|
+
console.log(`sizeBytes: ${reconstructed.length}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
uploadFile,
|
|
204
|
+
downloadFile
|
|
205
|
+
};
|