nodio-cli 1.1.1 → 1.1.3
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 +18 -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 +72 -26
- package/src/user/commands.js +186 -2
- package/src/user/index.js +50 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodio-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
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,7 +4,9 @@ const cors = require('cors');
|
|
|
4
4
|
const mongoose = require('mongoose');
|
|
5
5
|
const { getServerConfig } = require('./config');
|
|
6
6
|
const { buildRoutes } = require('./routes');
|
|
7
|
-
const
|
|
7
|
+
const authRoutes = require('./routes/auth');
|
|
8
|
+
const { NodeModel, FileModel } = require('./models');
|
|
9
|
+
const verifyToken = require('./middleware/verifyToken');
|
|
8
10
|
const { markNodeOfflineAndRecover } = require('./services');
|
|
9
11
|
const { getWalletBalance } = require('../../services/filecoin');
|
|
10
12
|
|
|
@@ -29,15 +31,29 @@ async function startServer() {
|
|
|
29
31
|
callback(new Error('Not allowed by CORS'));
|
|
30
32
|
},
|
|
31
33
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
32
|
-
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
34
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-token'],
|
|
33
35
|
optionsSuccessStatus: 200
|
|
34
36
|
};
|
|
35
37
|
|
|
36
38
|
app.use(cors(corsOptions));
|
|
37
39
|
app.options(/.*/, cors(corsOptions));
|
|
38
40
|
app.use(express.json({ limit: '50mb' }));
|
|
41
|
+
app.use('/api/auth', authRoutes);
|
|
39
42
|
app.use('/api', buildRoutes(config));
|
|
40
43
|
|
|
44
|
+
app.get('/api/files', verifyToken, async (req, res, next) => {
|
|
45
|
+
try {
|
|
46
|
+
const files = await FileModel.find({ userId: req.userId })
|
|
47
|
+
.sort({ createdAt: -1 })
|
|
48
|
+
.select('fileId originalName sizeBytes createdAt filecoinBackedUp filecoinCid')
|
|
49
|
+
.lean();
|
|
50
|
+
|
|
51
|
+
res.json({ files });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
next(error);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
41
57
|
app.use((error, _req, res, _next) => {
|
|
42
58
|
const status = error.statusCode || 500;
|
|
43
59
|
const message = error.message || 'internal server error';
|
|
@@ -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,7 @@ const {
|
|
|
9
9
|
ReplicationTaskModel,
|
|
10
10
|
RelayTaskModel
|
|
11
11
|
} = require('./models');
|
|
12
|
+
const verifyToken = require('./middleware/verifyToken');
|
|
12
13
|
const { uploadToFilecoin } = require('../../services/filecoin');
|
|
13
14
|
const {
|
|
14
15
|
chooseDistinctOnlineNodes,
|
|
@@ -56,6 +57,19 @@ function buildRoutes(config) {
|
|
|
56
57
|
res.json({ ok: true, service: 'nodio-server' });
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
router.get('/files', verifyToken, async (req, res, next) => {
|
|
61
|
+
try {
|
|
62
|
+
const files = await FileModel.find({ userId: req.userId })
|
|
63
|
+
.sort({ createdAt: -1 })
|
|
64
|
+
.select('fileId originalName sizeBytes createdAt filecoinBackedUp filecoinCid')
|
|
65
|
+
.lean();
|
|
66
|
+
|
|
67
|
+
res.json({ files });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
next(error);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
59
73
|
router.post('/nodes/register', async (req, res, next) => {
|
|
60
74
|
try {
|
|
61
75
|
const { nodeId, deviceKey, nodeKey, knownNodeIds, url, capacityBytes, freeBytes } = req.body;
|
|
@@ -511,9 +525,9 @@ function buildRoutes(config) {
|
|
|
511
525
|
}
|
|
512
526
|
});
|
|
513
527
|
|
|
514
|
-
router.post('/files/register', async (req, res, next) => {
|
|
528
|
+
router.post('/files/register', verifyToken, async (req, res, next) => {
|
|
515
529
|
try {
|
|
516
|
-
const { fileId, originalName, sizeBytes, shardCount, cipher, metadata } = req.body;
|
|
530
|
+
const { fileId, originalName, sizeBytes, shardCount, cipher, metadata, encryptedAESKey } = req.body;
|
|
517
531
|
if (!originalName || !Number.isFinite(Number(sizeBytes)) || Number(sizeBytes) < 0) {
|
|
518
532
|
return res.status(400).json({ error: 'originalName and sizeBytes are required' });
|
|
519
533
|
}
|
|
@@ -521,17 +535,33 @@ function buildRoutes(config) {
|
|
|
521
535
|
const normalizedShardCount = parsePositiveInt(shardCount, 1);
|
|
522
536
|
const actualFileId = fileId || uuidv4();
|
|
523
537
|
|
|
538
|
+
const existing = await FileModel.findOne({ fileId: actualFileId }).lean();
|
|
539
|
+
if (existing?.userId && existing.userId !== req.userId) {
|
|
540
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const nextMetadata = metadata === undefined ? existing?.metadata || {} : metadata;
|
|
544
|
+
const update = {
|
|
545
|
+
fileId: actualFileId,
|
|
546
|
+
originalName,
|
|
547
|
+
sizeBytes: Number(sizeBytes),
|
|
548
|
+
shardCount: normalizedShardCount,
|
|
549
|
+
cipher: cipher || 'aes-256-gcm',
|
|
550
|
+
metadata: nextMetadata
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
if (!existing?.userId) {
|
|
554
|
+
update.userId = req.userId;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (typeof encryptedAESKey === 'string' && encryptedAESKey.trim()) {
|
|
558
|
+
update.encryptedAESKey = encryptedAESKey.trim();
|
|
559
|
+
}
|
|
560
|
+
|
|
524
561
|
const file = await FileModel.findOneAndUpdate(
|
|
525
562
|
{ fileId: actualFileId },
|
|
526
563
|
{
|
|
527
|
-
$set:
|
|
528
|
-
fileId: actualFileId,
|
|
529
|
-
originalName,
|
|
530
|
-
sizeBytes: Number(sizeBytes),
|
|
531
|
-
shardCount: normalizedShardCount,
|
|
532
|
-
cipher: cipher || 'aes-256-gcm',
|
|
533
|
-
metadata: metadata || {}
|
|
534
|
-
}
|
|
564
|
+
$set: update
|
|
535
565
|
},
|
|
536
566
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
537
567
|
);
|
|
@@ -542,7 +572,7 @@ function buildRoutes(config) {
|
|
|
542
572
|
}
|
|
543
573
|
});
|
|
544
574
|
|
|
545
|
-
router.post('/files/:fileId/filecoin', async (req, res, next) => {
|
|
575
|
+
router.post('/files/:fileId/filecoin', verifyToken, async (req, res, next) => {
|
|
546
576
|
try {
|
|
547
577
|
const { fileId } = req.params;
|
|
548
578
|
const { filecoinCid, filecoinBackedUp } = req.body;
|
|
@@ -554,7 +584,15 @@ function buildRoutes(config) {
|
|
|
554
584
|
return res.status(400).json({ error: 'filecoinCid is required' });
|
|
555
585
|
}
|
|
556
586
|
|
|
557
|
-
const file = await FileModel.
|
|
587
|
+
const file = await FileModel.findOne({ fileId });
|
|
588
|
+
if (!file) {
|
|
589
|
+
return res.status(404).json({ error: 'file not found' });
|
|
590
|
+
}
|
|
591
|
+
if (file.userId && file.userId !== req.userId) {
|
|
592
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const updated = await FileModel.findOneAndUpdate(
|
|
558
596
|
{ fileId },
|
|
559
597
|
{
|
|
560
598
|
$set: {
|
|
@@ -565,17 +603,13 @@ function buildRoutes(config) {
|
|
|
565
603
|
{ new: true }
|
|
566
604
|
);
|
|
567
605
|
|
|
568
|
-
|
|
569
|
-
return res.status(404).json({ error: 'file not found' });
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
res.json({ ok: true, fileId: file.fileId, filecoinCid: file.filecoinCid });
|
|
606
|
+
res.json({ ok: true, fileId: updated.fileId, filecoinCid: updated.filecoinCid });
|
|
573
607
|
} catch (error) {
|
|
574
608
|
next(error);
|
|
575
609
|
}
|
|
576
610
|
});
|
|
577
611
|
|
|
578
|
-
router.post('/files/:fileId/filecoin/upload', async (req, res, next) => {
|
|
612
|
+
router.post('/files/:fileId/filecoin/upload', verifyToken, async (req, res, next) => {
|
|
579
613
|
try {
|
|
580
614
|
const { fileId } = req.params;
|
|
581
615
|
const { dataBase64 } = req.body;
|
|
@@ -597,7 +631,15 @@ function buildRoutes(config) {
|
|
|
597
631
|
return res.status(502).json({ error: 'filecoin upload failed' });
|
|
598
632
|
}
|
|
599
633
|
|
|
600
|
-
const file = await FileModel.
|
|
634
|
+
const file = await FileModel.findOne({ fileId });
|
|
635
|
+
if (!file) {
|
|
636
|
+
return res.status(404).json({ error: 'file not found' });
|
|
637
|
+
}
|
|
638
|
+
if (file.userId && file.userId !== req.userId) {
|
|
639
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const updated = await FileModel.findOneAndUpdate(
|
|
601
643
|
{ fileId },
|
|
602
644
|
{
|
|
603
645
|
$set: {
|
|
@@ -608,11 +650,7 @@ function buildRoutes(config) {
|
|
|
608
650
|
{ new: true }
|
|
609
651
|
);
|
|
610
652
|
|
|
611
|
-
|
|
612
|
-
return res.status(404).json({ error: 'file not found' });
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
res.json({ ok: true, fileId: file.fileId, filecoinCid: cid });
|
|
653
|
+
res.json({ ok: true, fileId: updated.fileId, filecoinCid: cid });
|
|
616
654
|
} catch (error) {
|
|
617
655
|
next(error);
|
|
618
656
|
}
|
|
@@ -696,13 +734,16 @@ function buildRoutes(config) {
|
|
|
696
734
|
}
|
|
697
735
|
});
|
|
698
736
|
|
|
699
|
-
router.delete('/files/:fileId', async (req, res, next) => {
|
|
737
|
+
router.delete('/files/:fileId', verifyToken, async (req, res, next) => {
|
|
700
738
|
try {
|
|
701
739
|
const { fileId } = req.params;
|
|
702
740
|
const file = await FileModel.findOne({ fileId }).lean();
|
|
703
741
|
if (!file) {
|
|
704
742
|
return res.status(404).json({ error: 'file not found' });
|
|
705
743
|
}
|
|
744
|
+
if (file.userId && file.userId !== req.userId) {
|
|
745
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
746
|
+
}
|
|
706
747
|
|
|
707
748
|
const shards = await ShardModel.find({ fileId }).select('shardId').lean();
|
|
708
749
|
const shardIds = shards.map((shard) => shard.shardId);
|
|
@@ -771,13 +812,16 @@ function buildRoutes(config) {
|
|
|
771
812
|
}
|
|
772
813
|
});
|
|
773
814
|
|
|
774
|
-
router.get('/files/:fileId/manifest', async (req, res, next) => {
|
|
815
|
+
router.get('/files/:fileId/manifest', verifyToken, async (req, res, next) => {
|
|
775
816
|
try {
|
|
776
817
|
const { fileId } = req.params;
|
|
777
818
|
const file = await FileModel.findOne({ fileId }).lean();
|
|
778
819
|
if (!file) {
|
|
779
820
|
return res.status(404).json({ error: 'file not found' });
|
|
780
821
|
}
|
|
822
|
+
if (file.userId && file.userId !== req.userId) {
|
|
823
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
824
|
+
}
|
|
781
825
|
|
|
782
826
|
const shards = await ShardModel.find({ fileId }).sort({ order: 1 }).lean();
|
|
783
827
|
const placements = await ShardPlacementModel.find({ fileId, status: 'available' }).lean();
|
|
@@ -813,6 +857,8 @@ function buildRoutes(config) {
|
|
|
813
857
|
sizeBytes: file.sizeBytes,
|
|
814
858
|
shardCount: file.shardCount,
|
|
815
859
|
cipher: file.cipher,
|
|
860
|
+
userId: file.userId || null,
|
|
861
|
+
encryptedAESKey: file.encryptedAESKey || null,
|
|
816
862
|
metadata: file.metadata,
|
|
817
863
|
filecoinCid: file.filecoinCid || null,
|
|
818
864
|
filecoinBackedUp: Boolean(file.filecoinBackedUp)
|
package/src/user/commands.js
CHANGED
|
@@ -2,10 +2,156 @@ 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
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
|
+
}
|
|
131
|
+
|
|
132
|
+
async function listFiles(options) {
|
|
133
|
+
const serverUrl = options.server;
|
|
134
|
+
const session = await requireSession();
|
|
135
|
+
const api = createApiClient(serverUrl);
|
|
136
|
+
attachSessionToken(api, session);
|
|
137
|
+
const response = await api.get('/files');
|
|
138
|
+
const files = response.data?.files || [];
|
|
139
|
+
|
|
140
|
+
if (files.length === 0) {
|
|
141
|
+
console.log('No files found for this account.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
console.log(`fileId: ${file.fileId}`);
|
|
147
|
+
console.log(`name: ${file.originalName}`);
|
|
148
|
+
console.log(`sizeBytes: ${file.sizeBytes}`);
|
|
149
|
+
console.log(`createdAt: ${file.createdAt}`);
|
|
150
|
+
console.log(`filecoinBackedUp: ${Boolean(file.filecoinBackedUp)}`);
|
|
151
|
+
console.log(`filecoinCid: ${file.filecoinCid || ''}`);
|
|
152
|
+
console.log('---');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
9
155
|
|
|
10
156
|
function splitBuffer(buffer, shardSizeBytes) {
|
|
11
157
|
if (shardSizeBytes <= 0) {
|
|
@@ -261,6 +407,9 @@ async function uploadFile(options) {
|
|
|
261
407
|
const directTimeoutMs = Number(options.directTimeoutMs || 1200);
|
|
262
408
|
const relayFirst = Boolean(options.relayFirst);
|
|
263
409
|
|
|
410
|
+
const session = await requireSession();
|
|
411
|
+
const masterKey = decryptMasterKey(session.encryptedMasterKey, session.apiToken);
|
|
412
|
+
|
|
264
413
|
if (!Number.isFinite(shardSizeMb) || shardSizeMb <= 0) {
|
|
265
414
|
throw new Error('shard-size-mb must be greater than 0');
|
|
266
415
|
}
|
|
@@ -277,8 +426,11 @@ async function uploadFile(options) {
|
|
|
277
426
|
|
|
278
427
|
const keyBuffer = options.keyBase64 ? parseAesKey(options.keyBase64) : crypto.randomBytes(32);
|
|
279
428
|
const keyBase64 = keyBuffer.toString('base64');
|
|
429
|
+
const encryptedKeyPayload = encryptAes256Gcm(keyBuffer, masterKey);
|
|
430
|
+
const encryptedAESKey = packEncryptedKey(encryptedKeyPayload);
|
|
280
431
|
|
|
281
432
|
const api = createApiClient(serverUrl);
|
|
433
|
+
attachSessionToken(api, session);
|
|
282
434
|
const fileId = options.fileId || uuidv4();
|
|
283
435
|
const originalName = path.basename(filePath);
|
|
284
436
|
|
|
@@ -288,6 +440,7 @@ async function uploadFile(options) {
|
|
|
288
440
|
sizeBytes: plainBuffer.length,
|
|
289
441
|
shardCount: chunks.length,
|
|
290
442
|
cipher: 'aes-256-gcm',
|
|
443
|
+
encryptedAESKey,
|
|
291
444
|
metadata: {
|
|
292
445
|
encryption: {
|
|
293
446
|
algorithm: 'aes-256-gcm',
|
|
@@ -415,6 +568,7 @@ async function uploadFile(options) {
|
|
|
415
568
|
sizeBytes: plainBuffer.length,
|
|
416
569
|
shardCount: chunks.length,
|
|
417
570
|
cipher: 'aes-256-gcm',
|
|
571
|
+
encryptedAESKey,
|
|
418
572
|
metadata: {
|
|
419
573
|
encryption: {
|
|
420
574
|
algorithm: 'aes-256-gcm',
|
|
@@ -443,20 +597,43 @@ async function downloadFile(options) {
|
|
|
443
597
|
const fileId = options.fileId;
|
|
444
598
|
const serverUrl = options.server;
|
|
445
599
|
const output = options.output ? path.resolve(options.output) : path.resolve(`./${fileId}.downloaded`);
|
|
446
|
-
const keyBuffer = parseAesKey(options.keyBase64);
|
|
447
600
|
const directTimeoutMs = Number(options.directTimeoutMs || 1200);
|
|
448
601
|
const relayFirst = Boolean(options.relayFirst);
|
|
449
602
|
|
|
603
|
+
const session = await requireSession();
|
|
604
|
+
const masterKey = decryptMasterKey(session.encryptedMasterKey, session.apiToken);
|
|
605
|
+
|
|
450
606
|
if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
|
|
451
607
|
throw new Error('direct-timeout-ms must be greater than 0');
|
|
452
608
|
}
|
|
453
609
|
|
|
454
610
|
const api = createApiClient(serverUrl);
|
|
611
|
+
attachSessionToken(api, session);
|
|
455
612
|
const manifestResponse = await api.get(`/files/${fileId}/manifest`);
|
|
456
613
|
const manifest = manifestResponse.data;
|
|
457
614
|
const metadata = manifest.file?.metadata || {};
|
|
458
615
|
const encryption = metadata.encryption;
|
|
459
616
|
|
|
617
|
+
let keyBuffer = null;
|
|
618
|
+
if (options.keyBase64) {
|
|
619
|
+
keyBuffer = parseAesKey(options.keyBase64);
|
|
620
|
+
} else if (manifest.file?.encryptedAESKey) {
|
|
621
|
+
const encryptedKeyPayload = unpackEncryptedKey(manifest.file.encryptedAESKey);
|
|
622
|
+
keyBuffer = decryptAes256Gcm(
|
|
623
|
+
encryptedKeyPayload.cipherText,
|
|
624
|
+
masterKey,
|
|
625
|
+
encryptedKeyPayload.iv,
|
|
626
|
+
encryptedKeyPayload.authTag
|
|
627
|
+
);
|
|
628
|
+
if (!Buffer.isBuffer(keyBuffer) || keyBuffer.length !== 32) {
|
|
629
|
+
throw new Error('invalid decrypted AES key length');
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (!keyBuffer) {
|
|
634
|
+
throw new Error('missing encryption key; provide --key-base64 or login to access encrypted keys');
|
|
635
|
+
}
|
|
636
|
+
|
|
460
637
|
if (!encryption || encryption.algorithm !== 'aes-256-gcm') {
|
|
461
638
|
throw new Error('missing or unsupported encryption metadata');
|
|
462
639
|
}
|
|
@@ -566,6 +743,8 @@ async function deleteFile(options) {
|
|
|
566
743
|
const serverUrl = options.server;
|
|
567
744
|
|
|
568
745
|
const api = createApiClient(serverUrl);
|
|
746
|
+
const session = await requireSession();
|
|
747
|
+
attachSessionToken(api, session);
|
|
569
748
|
const response = await api.delete(`/files/${fileId}`);
|
|
570
749
|
const payload = response.data || {};
|
|
571
750
|
|
|
@@ -582,5 +761,10 @@ async function deleteFile(options) {
|
|
|
582
761
|
module.exports = {
|
|
583
762
|
uploadFile,
|
|
584
763
|
downloadFile,
|
|
585
|
-
deleteFile
|
|
764
|
+
deleteFile,
|
|
765
|
+
login,
|
|
766
|
+
register,
|
|
767
|
+
logout,
|
|
768
|
+
whoami,
|
|
769
|
+
listFiles
|
|
586
770
|
};
|
package/src/user/index.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const { Command } = require('commander');
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
uploadFile,
|
|
5
|
+
downloadFile,
|
|
6
|
+
deleteFile,
|
|
7
|
+
login,
|
|
8
|
+
logout,
|
|
9
|
+
register,
|
|
10
|
+
whoami,
|
|
11
|
+
listFiles
|
|
12
|
+
} = require('./commands');
|
|
4
13
|
|
|
5
14
|
const program = new Command();
|
|
6
15
|
|
|
@@ -25,7 +34,7 @@ program
|
|
|
25
34
|
.command('download')
|
|
26
35
|
.description('Download, verify, decrypt, and reconstruct a file')
|
|
27
36
|
.requiredOption('--file-id <id>', 'file ID to download')
|
|
28
|
-
.
|
|
37
|
+
.option('--key-base64 <key>', '32-byte AES key in base64 from upload output')
|
|
29
38
|
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
30
39
|
.option('--output <path>', 'output file path')
|
|
31
40
|
.option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '1200')
|
|
@@ -43,6 +52,45 @@ program
|
|
|
43
52
|
await deleteFile(options);
|
|
44
53
|
});
|
|
45
54
|
|
|
55
|
+
program
|
|
56
|
+
.command('login')
|
|
57
|
+
.description('Login and persist a session locally')
|
|
58
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
59
|
+
.action(async (options) => {
|
|
60
|
+
await login(options);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
program
|
|
64
|
+
.command('logout')
|
|
65
|
+
.description('Clear local session data')
|
|
66
|
+
.action(async () => {
|
|
67
|
+
await logout();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
program
|
|
71
|
+
.command('register')
|
|
72
|
+
.description('Create an account and persist a session locally')
|
|
73
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
74
|
+
.action(async (options) => {
|
|
75
|
+
await register(options);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
program
|
|
79
|
+
.command('whoami')
|
|
80
|
+
.description('Show the current authenticated user')
|
|
81
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
82
|
+
.action(async (options) => {
|
|
83
|
+
await whoami(options);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
program
|
|
87
|
+
.command('files')
|
|
88
|
+
.description('List files uploaded by the authenticated user')
|
|
89
|
+
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
90
|
+
.action(async (options) => {
|
|
91
|
+
await listFiles(options);
|
|
92
|
+
});
|
|
93
|
+
|
|
46
94
|
program.parseAsync(process.argv).catch((error) => {
|
|
47
95
|
const apiErrorMessage = error.response?.data?.error;
|
|
48
96
|
if (apiErrorMessage) {
|