nodio-cli 1.1.0 → 1.1.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 -1
- package/render.yaml +8 -0
- package/src/cli/session.js +93 -0
- package/src/server/index.js +4 -2
- package/src/server/middleware/verifyToken.js +35 -0
- package/src/server/models/user.js +17 -0
- package/src/server/models.js +4 -0
- package/src/server/routes/auth.js +96 -0
- package/src/server/routes.js +92 -15
- package/src/user/commands.js +180 -37
- package/src/user/index.js +33 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodio-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Nodio distributed storage network",
|
|
5
5
|
"main": "src/server/index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@filoz/synapse-sdk": "^0.41.0",
|
|
28
28
|
"axios": "^1.11.0",
|
|
29
|
+
"argon2": "^0.43.0",
|
|
30
|
+
"bcrypt": "^6.0.0",
|
|
29
31
|
"commander": "^14.0.1",
|
|
30
32
|
"cors": "^2.8.5",
|
|
31
33
|
"dotenv": "^17.4.2",
|
package/render.yaml
CHANGED
|
@@ -12,6 +12,14 @@ services:
|
|
|
12
12
|
value: 20
|
|
13
13
|
- key: NODIO_MONGO_URI
|
|
14
14
|
sync: false
|
|
15
|
+
- key: NODIO_WALLET_PRIVATE_KEY
|
|
16
|
+
sync: false
|
|
17
|
+
- key: NODIO_WALLET_ADDRESS
|
|
18
|
+
sync: false
|
|
19
|
+
- key: FILECOIN_NETWORK
|
|
20
|
+
sync: false
|
|
21
|
+
- key: FILECOIN_RPC_URL
|
|
22
|
+
sync: false
|
|
15
23
|
- key: NODIO_HEARTBEAT_INTERVAL_MS
|
|
16
24
|
value: "30000"
|
|
17
25
|
- key: NODIO_OFFLINE_AFTER_MISSES
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const argon2 = require('argon2');
|
|
6
|
+
const { encryptAes256Gcm, decryptAes256Gcm } = require('../common/crypto');
|
|
7
|
+
|
|
8
|
+
const SESSION_DIR = '.nodio';
|
|
9
|
+
const SESSION_FILE = 'session.json';
|
|
10
|
+
|
|
11
|
+
function getSessionPath() {
|
|
12
|
+
return path.join(os.homedir(), SESSION_DIR, SESSION_FILE);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function loadSession() {
|
|
16
|
+
try {
|
|
17
|
+
const raw = await fs.readFile(getSessionPath(), 'utf8');
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error.code === 'ENOENT') {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function saveSession(session) {
|
|
28
|
+
const dir = path.join(os.homedir(), SESSION_DIR);
|
|
29
|
+
await fs.mkdir(dir, { recursive: true });
|
|
30
|
+
await fs.writeFile(getSessionPath(), JSON.stringify(session, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function clearSession() {
|
|
34
|
+
try {
|
|
35
|
+
await fs.unlink(getSessionPath());
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== 'ENOENT') {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function deriveMasterKey(password, argon2Salt) {
|
|
44
|
+
const saltBuffer = Buffer.from(argon2Salt, 'hex');
|
|
45
|
+
return argon2.hash(password, {
|
|
46
|
+
type: argon2.argon2id,
|
|
47
|
+
salt: saltBuffer,
|
|
48
|
+
hashLength: 32,
|
|
49
|
+
raw: true
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function deriveSessionKey(apiToken) {
|
|
54
|
+
return crypto.createHash('sha256').update(apiToken).digest();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function packEncryptedKey({ iv, authTag, cipherText }) {
|
|
58
|
+
return `${iv}:${authTag}:${cipherText.toString('base64')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function unpackEncryptedKey(payload) {
|
|
62
|
+
const [iv, authTag, cipherText] = String(payload || '').split(':');
|
|
63
|
+
if (!iv || !authTag || !cipherText) {
|
|
64
|
+
throw new Error('invalid encryptedMasterKey format');
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
iv,
|
|
68
|
+
authTag,
|
|
69
|
+
cipherText: Buffer.from(cipherText, 'base64')
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function encryptMasterKey(masterKeyBuffer, apiToken) {
|
|
74
|
+
const sessionKey = deriveSessionKey(apiToken);
|
|
75
|
+
const encrypted = encryptAes256Gcm(masterKeyBuffer, sessionKey);
|
|
76
|
+
return packEncryptedKey(encrypted);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function decryptMasterKey(encryptedMasterKey, apiToken) {
|
|
80
|
+
const sessionKey = deriveSessionKey(apiToken);
|
|
81
|
+
const payload = unpackEncryptedKey(encryptedMasterKey);
|
|
82
|
+
return decryptAes256Gcm(payload.cipherText, sessionKey, payload.iv, payload.authTag);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
getSessionPath,
|
|
87
|
+
loadSession,
|
|
88
|
+
saveSession,
|
|
89
|
+
clearSession,
|
|
90
|
+
deriveMasterKey,
|
|
91
|
+
encryptMasterKey,
|
|
92
|
+
decryptMasterKey
|
|
93
|
+
};
|
package/src/server/index.js
CHANGED
|
@@ -4,6 +4,7 @@ const cors = require('cors');
|
|
|
4
4
|
const mongoose = require('mongoose');
|
|
5
5
|
const { getServerConfig } = require('./config');
|
|
6
6
|
const { buildRoutes } = require('./routes');
|
|
7
|
+
const authRoutes = require('./routes/auth');
|
|
7
8
|
const { NodeModel } = require('./models');
|
|
8
9
|
const { markNodeOfflineAndRecover } = require('./services');
|
|
9
10
|
const { getWalletBalance } = require('../../services/filecoin');
|
|
@@ -29,13 +30,14 @@ async function startServer() {
|
|
|
29
30
|
callback(new Error('Not allowed by CORS'));
|
|
30
31
|
},
|
|
31
32
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
32
|
-
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
33
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-token'],
|
|
33
34
|
optionsSuccessStatus: 200
|
|
34
35
|
};
|
|
35
36
|
|
|
36
37
|
app.use(cors(corsOptions));
|
|
37
38
|
app.options(/.*/, cors(corsOptions));
|
|
38
|
-
app.use(express.json({ limit: '
|
|
39
|
+
app.use(express.json({ limit: '50mb' }));
|
|
40
|
+
app.use('/api/auth', authRoutes);
|
|
39
41
|
app.use('/api', buildRoutes(config));
|
|
40
42
|
|
|
41
43
|
app.use((error, _req, res, _next) => {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { UserModel } = require('../models');
|
|
2
|
+
|
|
3
|
+
function extractToken(req) {
|
|
4
|
+
const header = req.headers.authorization || '';
|
|
5
|
+
if (header.startsWith('Bearer ')) {
|
|
6
|
+
return header.slice('Bearer '.length).trim();
|
|
7
|
+
}
|
|
8
|
+
const apiTokenHeader = req.headers['x-api-token'];
|
|
9
|
+
if (typeof apiTokenHeader === 'string' && apiTokenHeader.trim()) {
|
|
10
|
+
return apiTokenHeader.trim();
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function verifyToken(req, res, next) {
|
|
16
|
+
try {
|
|
17
|
+
const token = extractToken(req);
|
|
18
|
+
if (!token) {
|
|
19
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const user = await UserModel.findOne({ apiToken: token }).lean();
|
|
23
|
+
if (!user) {
|
|
24
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
req.userId = user.userId;
|
|
28
|
+
req.user = user;
|
|
29
|
+
return next();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return next(error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = verifyToken;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const userSchema = new mongoose.Schema(
|
|
4
|
+
{
|
|
5
|
+
userId: { type: String, required: true, unique: true, index: true },
|
|
6
|
+
email: { type: String, required: true, unique: true, index: true },
|
|
7
|
+
passwordHash: { type: String, required: true },
|
|
8
|
+
argon2Salt: { type: String, required: true },
|
|
9
|
+
apiToken: { type: String, required: true, unique: true, index: true },
|
|
10
|
+
createdAt: { type: Date, default: Date.now }
|
|
11
|
+
},
|
|
12
|
+
{ timestamps: false }
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
UserModel: mongoose.model('User', userSchema)
|
|
17
|
+
};
|
package/src/server/models.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const mongoose = require('mongoose');
|
|
2
|
+
const { UserModel } = require('./models/user');
|
|
2
3
|
|
|
3
4
|
const nodeSchema = new mongoose.Schema(
|
|
4
5
|
{
|
|
@@ -23,6 +24,8 @@ const fileSchema = new mongoose.Schema(
|
|
|
23
24
|
sizeBytes: { type: Number, required: true, min: 0 },
|
|
24
25
|
shardCount: { type: Number, required: true, min: 1 },
|
|
25
26
|
cipher: { type: String, required: true, default: 'aes-256-gcm' },
|
|
27
|
+
userId: { type: String, default: null, index: true },
|
|
28
|
+
encryptedAESKey: { type: String, default: null },
|
|
26
29
|
filecoinCid: { type: String, default: null },
|
|
27
30
|
filecoinBackedUp: { type: Boolean, default: false },
|
|
28
31
|
metadata: { type: mongoose.Schema.Types.Mixed, default: {} }
|
|
@@ -110,6 +113,7 @@ const relayTaskSchema = new mongoose.Schema(
|
|
|
110
113
|
relayTaskSchema.index({ opId: 1, nodeId: 1, taskType: 1 });
|
|
111
114
|
|
|
112
115
|
module.exports = {
|
|
116
|
+
UserModel,
|
|
113
117
|
NodeModel: mongoose.model('Node', nodeSchema),
|
|
114
118
|
FileModel: mongoose.model('File', fileSchema),
|
|
115
119
|
ShardModel: mongoose.model('Shard', shardSchema),
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const bcrypt = require('bcrypt');
|
|
4
|
+
const { v4: uuidv4 } = require('uuid');
|
|
5
|
+
const { UserModel } = require('../models');
|
|
6
|
+
const verifyToken = require('../middleware/verifyToken');
|
|
7
|
+
|
|
8
|
+
function isValidEmail(email) {
|
|
9
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const router = express.Router();
|
|
13
|
+
|
|
14
|
+
router.post('/register', async (req, res, next) => {
|
|
15
|
+
try {
|
|
16
|
+
const email = String(req.body?.email || '').trim().toLowerCase();
|
|
17
|
+
const password = String(req.body?.password || '');
|
|
18
|
+
|
|
19
|
+
if (!email || !isValidEmail(email)) {
|
|
20
|
+
return res.status(400).json({ error: 'Invalid email format' });
|
|
21
|
+
}
|
|
22
|
+
if (!password) {
|
|
23
|
+
return res.status(400).json({ error: 'Password is required' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const existing = await UserModel.findOne({ email }).lean();
|
|
27
|
+
if (existing) {
|
|
28
|
+
return res.status(400).json({ error: 'Email already exists' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const passwordHash = await bcrypt.hash(password, 12);
|
|
32
|
+
const argon2Salt = crypto.randomBytes(32).toString('hex');
|
|
33
|
+
const apiToken = crypto.randomBytes(64).toString('hex');
|
|
34
|
+
const userId = uuidv4();
|
|
35
|
+
|
|
36
|
+
const user = await UserModel.create({
|
|
37
|
+
userId,
|
|
38
|
+
email,
|
|
39
|
+
passwordHash,
|
|
40
|
+
argon2Salt,
|
|
41
|
+
apiToken
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
res.json({
|
|
45
|
+
userId: user.userId,
|
|
46
|
+
apiToken: user.apiToken,
|
|
47
|
+
argon2Salt: user.argon2Salt
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
next(error);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
router.post('/login', async (req, res, next) => {
|
|
55
|
+
try {
|
|
56
|
+
const email = String(req.body?.email || '').trim().toLowerCase();
|
|
57
|
+
const password = String(req.body?.password || '');
|
|
58
|
+
|
|
59
|
+
if (!email || !password) {
|
|
60
|
+
return res.status(401).json({ error: 'Invalid email or password' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const user = await UserModel.findOne({ email });
|
|
64
|
+
if (!user) {
|
|
65
|
+
return res.status(401).json({ error: 'Invalid email or password' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const matches = await bcrypt.compare(password, user.passwordHash);
|
|
69
|
+
if (!matches) {
|
|
70
|
+
return res.status(401).json({ error: 'Invalid email or password' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
res.json({
|
|
74
|
+
userId: user.userId,
|
|
75
|
+
email: user.email,
|
|
76
|
+
apiToken: user.apiToken,
|
|
77
|
+
argon2Salt: user.argon2Salt
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
next(error);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
router.get('/me', verifyToken, async (req, res, next) => {
|
|
85
|
+
try {
|
|
86
|
+
res.json({
|
|
87
|
+
userId: req.user?.userId,
|
|
88
|
+
email: req.user?.email,
|
|
89
|
+
argon2Salt: req.user?.argon2Salt
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
next(error);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
module.exports = router;
|
package/src/server/routes.js
CHANGED
|
@@ -9,6 +9,8 @@ const {
|
|
|
9
9
|
ReplicationTaskModel,
|
|
10
10
|
RelayTaskModel
|
|
11
11
|
} = require('./models');
|
|
12
|
+
const verifyToken = require('./middleware/verifyToken');
|
|
13
|
+
const { uploadToFilecoin } = require('../../services/filecoin');
|
|
12
14
|
const {
|
|
13
15
|
chooseDistinctOnlineNodes,
|
|
14
16
|
ensureEmergencyReplicasForShard
|
|
@@ -510,9 +512,9 @@ function buildRoutes(config) {
|
|
|
510
512
|
}
|
|
511
513
|
});
|
|
512
514
|
|
|
513
|
-
router.post('/files/register', async (req, res, next) => {
|
|
515
|
+
router.post('/files/register', verifyToken, async (req, res, next) => {
|
|
514
516
|
try {
|
|
515
|
-
const { fileId, originalName, sizeBytes, shardCount, cipher, metadata } = req.body;
|
|
517
|
+
const { fileId, originalName, sizeBytes, shardCount, cipher, metadata, encryptedAESKey } = req.body;
|
|
516
518
|
if (!originalName || !Number.isFinite(Number(sizeBytes)) || Number(sizeBytes) < 0) {
|
|
517
519
|
return res.status(400).json({ error: 'originalName and sizeBytes are required' });
|
|
518
520
|
}
|
|
@@ -520,17 +522,33 @@ function buildRoutes(config) {
|
|
|
520
522
|
const normalizedShardCount = parsePositiveInt(shardCount, 1);
|
|
521
523
|
const actualFileId = fileId || uuidv4();
|
|
522
524
|
|
|
525
|
+
const existing = await FileModel.findOne({ fileId: actualFileId }).lean();
|
|
526
|
+
if (existing?.userId && existing.userId !== req.userId) {
|
|
527
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const nextMetadata = metadata === undefined ? existing?.metadata || {} : metadata;
|
|
531
|
+
const update = {
|
|
532
|
+
fileId: actualFileId,
|
|
533
|
+
originalName,
|
|
534
|
+
sizeBytes: Number(sizeBytes),
|
|
535
|
+
shardCount: normalizedShardCount,
|
|
536
|
+
cipher: cipher || 'aes-256-gcm',
|
|
537
|
+
metadata: nextMetadata
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
if (!existing?.userId) {
|
|
541
|
+
update.userId = req.userId;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (typeof encryptedAESKey === 'string' && encryptedAESKey.trim()) {
|
|
545
|
+
update.encryptedAESKey = encryptedAESKey.trim();
|
|
546
|
+
}
|
|
547
|
+
|
|
523
548
|
const file = await FileModel.findOneAndUpdate(
|
|
524
549
|
{ fileId: actualFileId },
|
|
525
550
|
{
|
|
526
|
-
$set:
|
|
527
|
-
fileId: actualFileId,
|
|
528
|
-
originalName,
|
|
529
|
-
sizeBytes: Number(sizeBytes),
|
|
530
|
-
shardCount: normalizedShardCount,
|
|
531
|
-
cipher: cipher || 'aes-256-gcm',
|
|
532
|
-
metadata: metadata || {}
|
|
533
|
-
}
|
|
551
|
+
$set: update
|
|
534
552
|
},
|
|
535
553
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
536
554
|
);
|
|
@@ -541,7 +559,7 @@ function buildRoutes(config) {
|
|
|
541
559
|
}
|
|
542
560
|
});
|
|
543
561
|
|
|
544
|
-
router.post('/files/:fileId/filecoin', async (req, res, next) => {
|
|
562
|
+
router.post('/files/:fileId/filecoin', verifyToken, async (req, res, next) => {
|
|
545
563
|
try {
|
|
546
564
|
const { fileId } = req.params;
|
|
547
565
|
const { filecoinCid, filecoinBackedUp } = req.body;
|
|
@@ -553,7 +571,15 @@ function buildRoutes(config) {
|
|
|
553
571
|
return res.status(400).json({ error: 'filecoinCid is required' });
|
|
554
572
|
}
|
|
555
573
|
|
|
556
|
-
const file = await FileModel.
|
|
574
|
+
const file = await FileModel.findOne({ fileId });
|
|
575
|
+
if (!file) {
|
|
576
|
+
return res.status(404).json({ error: 'file not found' });
|
|
577
|
+
}
|
|
578
|
+
if (file.userId && file.userId !== req.userId) {
|
|
579
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const updated = await FileModel.findOneAndUpdate(
|
|
557
583
|
{ fileId },
|
|
558
584
|
{
|
|
559
585
|
$set: {
|
|
@@ -564,11 +590,54 @@ function buildRoutes(config) {
|
|
|
564
590
|
{ new: true }
|
|
565
591
|
);
|
|
566
592
|
|
|
593
|
+
res.json({ ok: true, fileId: updated.fileId, filecoinCid: updated.filecoinCid });
|
|
594
|
+
} catch (error) {
|
|
595
|
+
next(error);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
router.post('/files/:fileId/filecoin/upload', verifyToken, async (req, res, next) => {
|
|
600
|
+
try {
|
|
601
|
+
const { fileId } = req.params;
|
|
602
|
+
const { dataBase64 } = req.body;
|
|
603
|
+
|
|
604
|
+
if (!fileId) {
|
|
605
|
+
return res.status(400).json({ error: 'fileId is required' });
|
|
606
|
+
}
|
|
607
|
+
if (!dataBase64 || typeof dataBase64 !== 'string') {
|
|
608
|
+
return res.status(400).json({ error: 'dataBase64 is required' });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const payload = Buffer.from(dataBase64, 'base64');
|
|
612
|
+
if (!payload || payload.length === 0) {
|
|
613
|
+
return res.status(400).json({ error: 'dataBase64 decoded to empty payload' });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const cid = await uploadToFilecoin(payload, fileId);
|
|
617
|
+
if (!cid) {
|
|
618
|
+
return res.status(502).json({ error: 'filecoin upload failed' });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const file = await FileModel.findOne({ fileId });
|
|
567
622
|
if (!file) {
|
|
568
623
|
return res.status(404).json({ error: 'file not found' });
|
|
569
624
|
}
|
|
625
|
+
if (file.userId && file.userId !== req.userId) {
|
|
626
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
627
|
+
}
|
|
570
628
|
|
|
571
|
-
|
|
629
|
+
const updated = await FileModel.findOneAndUpdate(
|
|
630
|
+
{ fileId },
|
|
631
|
+
{
|
|
632
|
+
$set: {
|
|
633
|
+
filecoinCid: cid,
|
|
634
|
+
filecoinBackedUp: true
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
{ new: true }
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
res.json({ ok: true, fileId: updated.fileId, filecoinCid: cid });
|
|
572
641
|
} catch (error) {
|
|
573
642
|
next(error);
|
|
574
643
|
}
|
|
@@ -652,13 +721,16 @@ function buildRoutes(config) {
|
|
|
652
721
|
}
|
|
653
722
|
});
|
|
654
723
|
|
|
655
|
-
router.delete('/files/:fileId', async (req, res, next) => {
|
|
724
|
+
router.delete('/files/:fileId', verifyToken, async (req, res, next) => {
|
|
656
725
|
try {
|
|
657
726
|
const { fileId } = req.params;
|
|
658
727
|
const file = await FileModel.findOne({ fileId }).lean();
|
|
659
728
|
if (!file) {
|
|
660
729
|
return res.status(404).json({ error: 'file not found' });
|
|
661
730
|
}
|
|
731
|
+
if (file.userId && file.userId !== req.userId) {
|
|
732
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
733
|
+
}
|
|
662
734
|
|
|
663
735
|
const shards = await ShardModel.find({ fileId }).select('shardId').lean();
|
|
664
736
|
const shardIds = shards.map((shard) => shard.shardId);
|
|
@@ -727,13 +799,16 @@ function buildRoutes(config) {
|
|
|
727
799
|
}
|
|
728
800
|
});
|
|
729
801
|
|
|
730
|
-
router.get('/files/:fileId/manifest', async (req, res, next) => {
|
|
802
|
+
router.get('/files/:fileId/manifest', verifyToken, async (req, res, next) => {
|
|
731
803
|
try {
|
|
732
804
|
const { fileId } = req.params;
|
|
733
805
|
const file = await FileModel.findOne({ fileId }).lean();
|
|
734
806
|
if (!file) {
|
|
735
807
|
return res.status(404).json({ error: 'file not found' });
|
|
736
808
|
}
|
|
809
|
+
if (file.userId && file.userId !== req.userId) {
|
|
810
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
811
|
+
}
|
|
737
812
|
|
|
738
813
|
const shards = await ShardModel.find({ fileId }).sort({ order: 1 }).lean();
|
|
739
814
|
const placements = await ShardPlacementModel.find({ fileId, status: 'available' }).lean();
|
|
@@ -769,6 +844,8 @@ function buildRoutes(config) {
|
|
|
769
844
|
sizeBytes: file.sizeBytes,
|
|
770
845
|
shardCount: file.shardCount,
|
|
771
846
|
cipher: file.cipher,
|
|
847
|
+
userId: file.userId || null,
|
|
848
|
+
encryptedAESKey: file.encryptedAESKey || null,
|
|
772
849
|
metadata: file.metadata,
|
|
773
850
|
filecoinCid: file.filecoinCid || null,
|
|
774
851
|
filecoinBackedUp: Boolean(file.filecoinBackedUp)
|
package/src/user/commands.js
CHANGED
|
@@ -2,10 +2,132 @@ const fs = require('fs/promises');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const axios = require('axios');
|
|
5
|
+
const readline = require('readline');
|
|
5
6
|
const { v4: uuidv4 } = require('uuid');
|
|
6
7
|
const { createApiClient, normalizeUrl } = require('./client');
|
|
7
8
|
const { encryptAes256Gcm, decryptAes256Gcm, sha256Hex } = require('../common/crypto');
|
|
8
|
-
const {
|
|
9
|
+
const { retrieveFromFilecoin } = require('../../services/filecoin');
|
|
10
|
+
const {
|
|
11
|
+
loadSession,
|
|
12
|
+
saveSession,
|
|
13
|
+
clearSession,
|
|
14
|
+
deriveMasterKey,
|
|
15
|
+
encryptMasterKey,
|
|
16
|
+
decryptMasterKey
|
|
17
|
+
} = require('../cli/session');
|
|
18
|
+
|
|
19
|
+
function promptInput(promptText) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
22
|
+
rl.question(promptText, (answer) => {
|
|
23
|
+
rl.close();
|
|
24
|
+
resolve(String(answer || '').trim());
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function promptHiddenInput(promptText) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
process.stdout.write(promptText);
|
|
32
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
33
|
+
rl.stdoutMuted = true;
|
|
34
|
+
rl._writeToOutput = function _writeToOutput(stringToWrite) {
|
|
35
|
+
if (rl.stdoutMuted) {
|
|
36
|
+
rl.output.write('*');
|
|
37
|
+
} else {
|
|
38
|
+
rl.output.write(stringToWrite);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
rl.question('', (answer) => {
|
|
42
|
+
rl.history = rl.history.slice(1);
|
|
43
|
+
rl.close();
|
|
44
|
+
process.stdout.write('\n');
|
|
45
|
+
resolve(String(answer || ''));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function requireSession() {
|
|
51
|
+
const session = await loadSession();
|
|
52
|
+
if (!session) {
|
|
53
|
+
console.log('Please login first using: npx nodio-cli nodio login');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
return session;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function attachSessionToken(api, session) {
|
|
60
|
+
if (session?.apiToken) {
|
|
61
|
+
api.defaults.headers['x-api-token'] = session.apiToken;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function packEncryptedKey({ iv, authTag, cipherText }) {
|
|
66
|
+
return `${iv}:${authTag}:${cipherText.toString('base64')}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function unpackEncryptedKey(payload) {
|
|
70
|
+
const [iv, authTag, cipherText] = String(payload || '').split(':');
|
|
71
|
+
if (!iv || !authTag || !cipherText) {
|
|
72
|
+
throw new Error('invalid encrypted key format');
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
iv,
|
|
76
|
+
authTag,
|
|
77
|
+
cipherText: Buffer.from(cipherText, 'base64')
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function buildSessionPayload(authResponse, password) {
|
|
82
|
+
const masterKey = await deriveMasterKey(password, authResponse.argon2Salt);
|
|
83
|
+
const encryptedMasterKey = encryptMasterKey(masterKey, authResponse.apiToken);
|
|
84
|
+
return {
|
|
85
|
+
apiToken: authResponse.apiToken,
|
|
86
|
+
argon2Salt: authResponse.argon2Salt,
|
|
87
|
+
userId: authResponse.userId,
|
|
88
|
+
email: authResponse.email || null,
|
|
89
|
+
encryptedMasterKey
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function login(options) {
|
|
94
|
+
const serverUrl = options.server;
|
|
95
|
+
const email = await promptInput('Email: ');
|
|
96
|
+
const password = await promptHiddenInput('Password: ');
|
|
97
|
+
|
|
98
|
+
const api = createApiClient(serverUrl);
|
|
99
|
+
const response = await api.post('/auth/login', { email, password });
|
|
100
|
+
const session = await buildSessionPayload(response.data, password);
|
|
101
|
+
await saveSession(session);
|
|
102
|
+
console.log('Logged in ✅');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function register(options) {
|
|
106
|
+
const serverUrl = options.server;
|
|
107
|
+
const email = await promptInput('Email: ');
|
|
108
|
+
const password = await promptHiddenInput('Password: ');
|
|
109
|
+
|
|
110
|
+
const api = createApiClient(serverUrl);
|
|
111
|
+
const response = await api.post('/auth/register', { email, password });
|
|
112
|
+
const session = await buildSessionPayload({ ...response.data, email }, password);
|
|
113
|
+
await saveSession(session);
|
|
114
|
+
console.log('Registered ✅');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function logout() {
|
|
118
|
+
await clearSession();
|
|
119
|
+
console.log('Logged out ✅');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function whoami(options) {
|
|
123
|
+
const serverUrl = options.server;
|
|
124
|
+
const session = await requireSession();
|
|
125
|
+
const api = createApiClient(serverUrl);
|
|
126
|
+
attachSessionToken(api, session);
|
|
127
|
+
const response = await api.get('/auth/me');
|
|
128
|
+
console.log(`email: ${response.data?.email || session.email || 'unknown'}`);
|
|
129
|
+
console.log(`userId: ${response.data?.userId || session.userId || 'unknown'}`);
|
|
130
|
+
}
|
|
9
131
|
|
|
10
132
|
function splitBuffer(buffer, shardSizeBytes) {
|
|
11
133
|
if (shardSizeBytes <= 0) {
|
|
@@ -88,27 +210,25 @@ function decryptShardsFromBuffers(orderedShards, shardMetaMap, encryptedShardBuf
|
|
|
88
210
|
return Buffer.concat(plainParts);
|
|
89
211
|
}
|
|
90
212
|
|
|
91
|
-
function
|
|
213
|
+
async function requestFilecoinBackup(api, fileBuffer, fileId) {
|
|
92
214
|
if (!fileBuffer || fileBuffer.length === 0) {
|
|
93
215
|
return;
|
|
94
216
|
}
|
|
95
217
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
})();
|
|
218
|
+
try {
|
|
219
|
+
await api.post(
|
|
220
|
+
`/files/${fileId}/filecoin/upload`,
|
|
221
|
+
{
|
|
222
|
+
dataBase64: fileBuffer.toString('base64')
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
timeout: 180000
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const message = error.response?.data?.error || error.message;
|
|
230
|
+
console.warn(`[filecoin] server upload failed for ${fileId}: ${message}`);
|
|
231
|
+
}
|
|
112
232
|
}
|
|
113
233
|
|
|
114
234
|
function fireAndForgetLayer1Reseed(api, fileId, orderedShards, encryptedShardBuffers, directTimeoutMs) {
|
|
@@ -263,6 +383,9 @@ async function uploadFile(options) {
|
|
|
263
383
|
const directTimeoutMs = Number(options.directTimeoutMs || 1200);
|
|
264
384
|
const relayFirst = Boolean(options.relayFirst);
|
|
265
385
|
|
|
386
|
+
const session = await requireSession();
|
|
387
|
+
const masterKey = decryptMasterKey(session.encryptedMasterKey, session.apiToken);
|
|
388
|
+
|
|
266
389
|
if (!Number.isFinite(shardSizeMb) || shardSizeMb <= 0) {
|
|
267
390
|
throw new Error('shard-size-mb must be greater than 0');
|
|
268
391
|
}
|
|
@@ -279,8 +402,11 @@ async function uploadFile(options) {
|
|
|
279
402
|
|
|
280
403
|
const keyBuffer = options.keyBase64 ? parseAesKey(options.keyBase64) : crypto.randomBytes(32);
|
|
281
404
|
const keyBase64 = keyBuffer.toString('base64');
|
|
405
|
+
const encryptedKeyPayload = encryptAes256Gcm(keyBuffer, masterKey);
|
|
406
|
+
const encryptedAESKey = packEncryptedKey(encryptedKeyPayload);
|
|
282
407
|
|
|
283
408
|
const api = createApiClient(serverUrl);
|
|
409
|
+
attachSessionToken(api, session);
|
|
284
410
|
const fileId = options.fileId || uuidv4();
|
|
285
411
|
const originalName = path.basename(filePath);
|
|
286
412
|
|
|
@@ -290,6 +416,7 @@ async function uploadFile(options) {
|
|
|
290
416
|
sizeBytes: plainBuffer.length,
|
|
291
417
|
shardCount: chunks.length,
|
|
292
418
|
cipher: 'aes-256-gcm',
|
|
419
|
+
encryptedAESKey,
|
|
293
420
|
metadata: {
|
|
294
421
|
encryption: {
|
|
295
422
|
algorithm: 'aes-256-gcm',
|
|
@@ -417,6 +544,7 @@ async function uploadFile(options) {
|
|
|
417
544
|
sizeBytes: plainBuffer.length,
|
|
418
545
|
shardCount: chunks.length,
|
|
419
546
|
cipher: 'aes-256-gcm',
|
|
547
|
+
encryptedAESKey,
|
|
420
548
|
metadata: {
|
|
421
549
|
encryption: {
|
|
422
550
|
algorithm: 'aes-256-gcm',
|
|
@@ -427,24 +555,10 @@ async function uploadFile(options) {
|
|
|
427
555
|
}
|
|
428
556
|
});
|
|
429
557
|
|
|
430
|
-
// Upload to Filecoin
|
|
558
|
+
// Upload to Filecoin via the central server (uses server-side wallet key)
|
|
431
559
|
const encryptedFileBuffer = Buffer.concat(encryptedShardBuffers);
|
|
432
|
-
console.log(`[filecoin] uploading to Filecoin (this may take a while)...`);
|
|
433
|
-
|
|
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
|
-
}
|
|
560
|
+
console.log(`[filecoin] uploading to Filecoin via central server (this may take a while)...`);
|
|
561
|
+
await requestFilecoinBackup(api, encryptedFileBuffer, fileId);
|
|
448
562
|
|
|
449
563
|
console.log(`Upload complete`);
|
|
450
564
|
console.log(`fileId: ${fileId}`);
|
|
@@ -459,20 +573,43 @@ async function downloadFile(options) {
|
|
|
459
573
|
const fileId = options.fileId;
|
|
460
574
|
const serverUrl = options.server;
|
|
461
575
|
const output = options.output ? path.resolve(options.output) : path.resolve(`./${fileId}.downloaded`);
|
|
462
|
-
const keyBuffer = parseAesKey(options.keyBase64);
|
|
463
576
|
const directTimeoutMs = Number(options.directTimeoutMs || 1200);
|
|
464
577
|
const relayFirst = Boolean(options.relayFirst);
|
|
465
578
|
|
|
579
|
+
const session = await requireSession();
|
|
580
|
+
const masterKey = decryptMasterKey(session.encryptedMasterKey, session.apiToken);
|
|
581
|
+
|
|
466
582
|
if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
|
|
467
583
|
throw new Error('direct-timeout-ms must be greater than 0');
|
|
468
584
|
}
|
|
469
585
|
|
|
470
586
|
const api = createApiClient(serverUrl);
|
|
587
|
+
attachSessionToken(api, session);
|
|
471
588
|
const manifestResponse = await api.get(`/files/${fileId}/manifest`);
|
|
472
589
|
const manifest = manifestResponse.data;
|
|
473
590
|
const metadata = manifest.file?.metadata || {};
|
|
474
591
|
const encryption = metadata.encryption;
|
|
475
592
|
|
|
593
|
+
let keyBuffer = null;
|
|
594
|
+
if (options.keyBase64) {
|
|
595
|
+
keyBuffer = parseAesKey(options.keyBase64);
|
|
596
|
+
} else if (manifest.file?.encryptedAESKey) {
|
|
597
|
+
const encryptedKeyPayload = unpackEncryptedKey(manifest.file.encryptedAESKey);
|
|
598
|
+
keyBuffer = decryptAes256Gcm(
|
|
599
|
+
encryptedKeyPayload.cipherText,
|
|
600
|
+
masterKey,
|
|
601
|
+
encryptedKeyPayload.iv,
|
|
602
|
+
encryptedKeyPayload.authTag
|
|
603
|
+
);
|
|
604
|
+
if (!Buffer.isBuffer(keyBuffer) || keyBuffer.length !== 32) {
|
|
605
|
+
throw new Error('invalid decrypted AES key length');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (!keyBuffer) {
|
|
610
|
+
throw new Error('missing encryption key; provide --key-base64 or login to access encrypted keys');
|
|
611
|
+
}
|
|
612
|
+
|
|
476
613
|
if (!encryption || encryption.algorithm !== 'aes-256-gcm') {
|
|
477
614
|
throw new Error('missing or unsupported encryption metadata');
|
|
478
615
|
}
|
|
@@ -582,6 +719,8 @@ async function deleteFile(options) {
|
|
|
582
719
|
const serverUrl = options.server;
|
|
583
720
|
|
|
584
721
|
const api = createApiClient(serverUrl);
|
|
722
|
+
const session = await requireSession();
|
|
723
|
+
attachSessionToken(api, session);
|
|
585
724
|
const response = await api.delete(`/files/${fileId}`);
|
|
586
725
|
const payload = response.data || {};
|
|
587
726
|
|
|
@@ -598,5 +737,9 @@ async function deleteFile(options) {
|
|
|
598
737
|
module.exports = {
|
|
599
738
|
uploadFile,
|
|
600
739
|
downloadFile,
|
|
601
|
-
deleteFile
|
|
740
|
+
deleteFile,
|
|
741
|
+
login,
|
|
742
|
+
register,
|
|
743
|
+
logout,
|
|
744
|
+
whoami
|
|
602
745
|
};
|
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, deleteFile } = require('./commands');
|
|
3
|
+
const { uploadFile, downloadFile, deleteFile, login, logout, register, whoami } = require('./commands');
|
|
4
4
|
|
|
5
5
|
const program = new Command();
|
|
6
6
|
|
|
@@ -25,7 +25,7 @@ program
|
|
|
25
25
|
.command('download')
|
|
26
26
|
.description('Download, verify, decrypt, and reconstruct a file')
|
|
27
27
|
.requiredOption('--file-id <id>', 'file ID to download')
|
|
28
|
-
.
|
|
28
|
+
.option('--key-base64 <key>', '32-byte AES key in base64 from upload output')
|
|
29
29
|
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
30
30
|
.option('--output <path>', 'output file path')
|
|
31
31
|
.option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '1200')
|
|
@@ -43,6 +43,37 @@ program
|
|
|
43
43
|
await deleteFile(options);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
program
|
|
47
|
+
.command('login')
|
|
48
|
+
.description('Login and persist a session locally')
|
|
49
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
50
|
+
.action(async (options) => {
|
|
51
|
+
await login(options);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
program
|
|
55
|
+
.command('logout')
|
|
56
|
+
.description('Clear local session data')
|
|
57
|
+
.action(async () => {
|
|
58
|
+
await logout();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
program
|
|
62
|
+
.command('register')
|
|
63
|
+
.description('Create an account and persist a session locally')
|
|
64
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
65
|
+
.action(async (options) => {
|
|
66
|
+
await register(options);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
program
|
|
70
|
+
.command('whoami')
|
|
71
|
+
.description('Show the current authenticated user')
|
|
72
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
73
|
+
.action(async (options) => {
|
|
74
|
+
await whoami(options);
|
|
75
|
+
});
|
|
76
|
+
|
|
46
77
|
program.parseAsync(process.argv).catch((error) => {
|
|
47
78
|
const apiErrorMessage = error.response?.data?.error;
|
|
48
79
|
if (apiErrorMessage) {
|