mbkbucket 2.1.1 → 3.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/.github/PACKAGE.md +6 -0
- package/.mbkbucket.config.example.json +14 -0
- package/README.md +261 -81
- package/cli.js +977 -0
- package/index.js +18 -140
- package/lib/index.js +7 -7
- package/package.json +11 -4
- package/public/bucketadmin.js +36 -4
- package/src/app.js +110 -0
- package/src/cli/auth-helper.js +223 -0
- package/{lib → src}/config/index.js +26 -19
- package/{lib/routes/api.js → src/controllers/api.controller.js} +125 -221
- package/{lib/routes/index.js → src/controllers/dashboard.controller.js} +8 -36
- package/src/controllers/info.controller.js +37 -0
- package/src/controllers/view.controller.js +276 -0
- package/src/middleware/async-handler.js +38 -0
- package/src/middleware/bucket-resolver.js +43 -0
- package/src/middleware/error-handler.js +34 -0
- package/src/middleware/rate-limiter.js +30 -0
- package/src/middleware/security.js +43 -0
- package/src/routes/api.routes.js +66 -0
- package/src/routes/index.js +22 -0
- package/src/routes/info.routes.js +10 -0
- package/src/routes/view.routes.js +25 -0
- package/{lib/s3.js → src/services/s3.service.js} +115 -191
- package/src/utils/errors.js +76 -0
- package/src/utils/helpers.js +173 -0
- package/{lib/debug.js → src/utils/logger.js} +5 -0
- package/test/config-validation.test.js +1 -1
- package/lib/routes/info.js +0 -28
- package/lib/routes/view.js +0 -531
- /package/{lib → src}/config/validation.js +0 -0
package/index.js
CHANGED
|
@@ -1,151 +1,29 @@
|
|
|
1
|
-
import express from "express";
|
|
2
|
-
import path from "path";
|
|
3
1
|
import dotenv from "dotenv";
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import rateLimit from 'express-rate-limit';
|
|
9
|
-
import bucketRoutes from "./lib/routes/index.js";
|
|
10
|
-
import { checkVersion } from "./lib/config/index.js";
|
|
11
|
-
import { packageJson } from "./lib/config/index.js";
|
|
12
|
-
import { createLogger } from "./lib/debug.js";
|
|
2
|
+
import { createApp } from "./src/app.js";
|
|
3
|
+
import { checkVersion } from "./src/config/index.js";
|
|
4
|
+
import { runHealthCheck } from "./src/services/s3.service.js";
|
|
5
|
+
import { createLogger } from "./src/utils/logger.js";
|
|
13
6
|
|
|
14
7
|
dotenv.config();
|
|
15
|
-
const debugServer = createLogger('server');
|
|
16
|
-
|
|
17
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
-
const __dirname = path.dirname(__filename);
|
|
19
|
-
const isDevMode = process.env.NODE_ENV === "dev";
|
|
20
|
-
|
|
21
|
-
const server = express();
|
|
22
|
-
server.set('trust proxy', 1);
|
|
23
8
|
|
|
24
|
-
server
|
|
25
|
-
|
|
26
|
-
// Rate limiting: general limiter for typical browsing/API usage and a stricter
|
|
27
|
-
// limiter for dashboard (admin) routes.
|
|
28
|
-
const generalLimiter = rateLimit({
|
|
29
|
-
windowMs: 2 * 60 * 1000, // 2 minutes
|
|
30
|
-
max: 150, // limit each IP to 150 requests per windowMs
|
|
31
|
-
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
|
32
|
-
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
|
33
|
-
handler: (req, res) => {
|
|
34
|
-
res.status(429).render('error.handlebars', { message: 'Too many requests from your IP. Try again later.', code: 429 });
|
|
35
|
-
}
|
|
36
|
-
});
|
|
9
|
+
const debugServer = createLogger('server');
|
|
10
|
+
const port = process.env.PORT || 3004;
|
|
37
11
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
server.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
12
|
+
// Check for package updates and S3 connectivity on startup
|
|
13
|
+
await checkVersion();
|
|
14
|
+
await runHealthCheck();
|
|
42
15
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
// can keep writing old URLs in browser history.
|
|
46
|
-
const dashboardAssetCacheControl = 'public, max-age=300, must-revalidate';
|
|
47
|
-
server.get('/mbkbucket/bucketadmin.css', (req, res) => {
|
|
48
|
-
res.set('Cache-Control', dashboardAssetCacheControl);
|
|
49
|
-
res.sendFile(path.join(__dirname, 'public', 'bucketadmin.css'), (err) => {
|
|
50
|
-
if (err) res.status(err.status || 404).end();
|
|
51
|
-
});
|
|
52
|
-
});
|
|
16
|
+
// Create and start the Express application
|
|
17
|
+
const server = createApp();
|
|
53
18
|
|
|
54
|
-
server.
|
|
55
|
-
|
|
56
|
-
res.sendFile(path.join(__dirname, 'public', 'bucketadmin.js'), (err) => {
|
|
57
|
-
if (err) res.status(err.status || 404).end();
|
|
58
|
-
});
|
|
19
|
+
server.listen(port, () => {
|
|
20
|
+
debugServer("Server running on http://localhost:%s", port);
|
|
59
21
|
});
|
|
60
22
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
layoutsDir: path.join(__dirname, "../../views/layouts"),
|
|
66
|
-
partialsDir: [
|
|
67
|
-
path.join(__dirname, "views/templates"),
|
|
68
|
-
path.join(__dirname, "views/templates/notice"),
|
|
69
|
-
path.join(__dirname, "views"),
|
|
70
|
-
path.join(__dirname, "views/partial"),
|
|
71
|
-
path.join(__dirname, "node_modules/mbkauthe/views"),
|
|
72
|
-
path.join(__dirname, "../mbkauthe/views")
|
|
73
|
-
],
|
|
74
|
-
cache: process.env.NODE_ENV === "production",
|
|
75
|
-
helpers: {
|
|
76
|
-
eq: function (a, b) {
|
|
77
|
-
return a === b;
|
|
78
|
-
},
|
|
79
|
-
mbkbucket_cachebuster: function () {
|
|
80
|
-
return "?v=" + packageJson.version;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}));
|
|
84
|
-
|
|
85
|
-
server.set("view engine", "handlebars");
|
|
86
|
-
server.set("views", [
|
|
87
|
-
path.join(__dirname, "views"),
|
|
88
|
-
path.join(__dirname, "node_modules/mbkauthe/views"),
|
|
89
|
-
path.join(__dirname, "../mbkauthe/views")
|
|
90
|
-
]);
|
|
91
|
-
|
|
92
|
-
server.use(mbkautheRoutes);
|
|
93
|
-
server.use(generalLimiter);
|
|
94
|
-
server.use(bucketRoutes);
|
|
95
|
-
if (isDevMode) {
|
|
96
|
-
debugServer("Dev mode is enabled. Starting server in dev mode.");
|
|
97
|
-
|
|
98
|
-
// Request timing middleware: logs method, url, status and elapsed ms
|
|
99
|
-
server.use((req, res, next) => {
|
|
100
|
-
const start = process.hrtime();
|
|
101
|
-
res.on('finish', () => {
|
|
102
|
-
const diff = process.hrtime(start);
|
|
103
|
-
const ms = diff[0] * 1000 + diff[1] / 1e6;
|
|
104
|
-
debugServer("[%s] %s %s %s - %s ms", new Date().toISOString(), req.method, req.originalUrl, res.statusCode, ms.toFixed(3));
|
|
105
|
-
});
|
|
106
|
-
next();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
server.get("/", async (req, res) => {
|
|
110
|
-
res.redirect("/mbkbucket");
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// 404 handler
|
|
114
|
-
server.use((req, res) => {
|
|
115
|
-
debugServer("Path not found: %s %s", req.method, req.url);
|
|
116
|
-
return res.status(404).render("Error/dError.handlebars", {
|
|
117
|
-
layout: false,
|
|
118
|
-
code: 404,
|
|
119
|
-
error: "Not Found",
|
|
120
|
-
message: "The requested page was not found.",
|
|
121
|
-
pagename: "Home",
|
|
122
|
-
page: "/",
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// Error handler
|
|
127
|
-
server.use((err, req, res, next) => {
|
|
128
|
-
console.error(`[mbkbucket] ${err.stack}`);
|
|
129
|
-
return res.status(500).render("Error/dError.handlebars", {
|
|
130
|
-
layout: false,
|
|
131
|
-
code: 500,
|
|
132
|
-
error: "Internal app Error",
|
|
133
|
-
message: "An unexpected error occurred on the app.",
|
|
134
|
-
details: err.message,
|
|
135
|
-
pagename: "Home",
|
|
136
|
-
page: "/",
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const port = process.env.PORT || 3004;
|
|
141
|
-
server.listen(port, async() => {
|
|
142
|
-
debugServer("Server running on http://localhost:%s", port);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
await checkVersion();
|
|
148
|
-
|
|
23
|
+
// Re-export the public API for consumers of this package (named exports only)
|
|
24
|
+
export * from "./src/services/s3.service.js";
|
|
25
|
+
export * from "./src/config/index.js";
|
|
26
|
+
export { default as bucket } from "./src/routes/index.js";
|
|
149
27
|
|
|
150
|
-
export
|
|
28
|
+
// Default export: the Express application instance
|
|
151
29
|
export default server;
|
package/lib/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// Barrel file: re-export all public items from
|
|
2
|
-
export * from '
|
|
3
|
-
export * from '
|
|
4
|
-
export { default as bucket } from '
|
|
1
|
+
// Barrel file: re-export all public items from the restructured src/ directory
|
|
2
|
+
export * from '../src/services/s3.service.js';
|
|
3
|
+
export * from '../src/config/index.js';
|
|
4
|
+
export { default as bucket } from '../src/routes/index.js';
|
|
5
5
|
|
|
6
|
-
import * as s3 from '
|
|
7
|
-
import * as config from '
|
|
8
|
-
import bucket from '
|
|
6
|
+
import * as s3 from '../src/services/s3.service.js';
|
|
7
|
+
import * as config from '../src/config/index.js';
|
|
8
|
+
import bucket from '../src/routes/index.js';
|
|
9
9
|
|
|
10
10
|
// Default aggregate export for convenience
|
|
11
11
|
const defaultExport = {
|
package/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mbkbucket",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Flexible S3/R2 bucket management library
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Flexible S3/R2 bucket management — library, Express router, and CLI for mbktech.org applications. Provides upload, download, listing, multipart utilities, signed URLs, and a command-line interface with built-in configuration validation and health checks",
|
|
5
5
|
"author": "Muhammad Bin Khalid",
|
|
6
6
|
"homepage": "https://github.com/MIbnEKhalid/mbkbucket#readme",
|
|
7
7
|
"license": "LGPL-3.0-only",
|
|
8
8
|
"main": "index.js",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"types": "index.d.ts",
|
|
11
|
+
"bin": {
|
|
12
|
+
"mbkbucket": "./cli.js"
|
|
13
|
+
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"dev": "cross-env NODE_ENV=dev nodemon index.js",
|
|
13
16
|
"test": "node --test"
|
|
@@ -21,7 +24,7 @@
|
|
|
21
24
|
"express": "^4.22.1",
|
|
22
25
|
"express-handlebars": "^8.0.3",
|
|
23
26
|
"express-rate-limit": "^7.0.1",
|
|
24
|
-
"mbkauthe": "5.
|
|
27
|
+
"mbkauthe": "5.3.2",
|
|
25
28
|
"multer": "^2.0.2",
|
|
26
29
|
"pg": "^8.16.3"
|
|
27
30
|
},
|
|
@@ -53,6 +56,10 @@
|
|
|
53
56
|
"idrivee2",
|
|
54
57
|
"storage",
|
|
55
58
|
"mbkauthe",
|
|
56
|
-
"mbktech.org"
|
|
59
|
+
"mbktech.org",
|
|
60
|
+
"cli",
|
|
61
|
+
"command-line",
|
|
62
|
+
"file-upload",
|
|
63
|
+
"file-download"
|
|
57
64
|
]
|
|
58
65
|
}
|
package/public/bucketadmin.js
CHANGED
|
@@ -37,6 +37,35 @@ function getDownloadUrl(encodedKey) {
|
|
|
37
37
|
return withBucketUrl(`/mbkbucket/download/${encodedKey}`);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Download a file via fetch + Blob, which allows multiple concurrent downloads
|
|
42
|
+
* (unlike <a href> navigation which cancels the previous download).
|
|
43
|
+
* @param {string} url - The download URL
|
|
44
|
+
* @param {string} filename - The filename to save as
|
|
45
|
+
*/
|
|
46
|
+
async function downloadFileViaFetch(url, filename) {
|
|
47
|
+
try {
|
|
48
|
+
const response = await fetch(url);
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
const err = await response.json().catch(() => ({}));
|
|
51
|
+
throw new Error(err.message || err.error || `Download failed (${response.status})`);
|
|
52
|
+
}
|
|
53
|
+
const blob = await response.blob();
|
|
54
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
55
|
+
const a = document.createElement('a');
|
|
56
|
+
a.href = objectUrl;
|
|
57
|
+
a.download = filename;
|
|
58
|
+
document.body.appendChild(a);
|
|
59
|
+
a.click();
|
|
60
|
+
document.body.removeChild(a);
|
|
61
|
+
// Revoke the object URL after a short delay to allow the browser to start the download
|
|
62
|
+
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error('Download failed:', err);
|
|
65
|
+
alert('Download failed: ' + err.message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
40
69
|
function stripApiPrefixFromPath(path, apiPrefix = '') {
|
|
41
70
|
const normalizedPath = String(path || '').replace(/^\/+/, '');
|
|
42
71
|
const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
@@ -686,7 +715,7 @@ function renderFilesTable(files, folders = [], prefix = '', apiPrefix = '') {
|
|
|
686
715
|
<button type="button" class="btn btn-info btn-sm copy-btn" data-key-enc="${encodedKey}" title="Copy Link">
|
|
687
716
|
<i class="fas fa-link"></i>
|
|
688
717
|
</button>
|
|
689
|
-
<a href="${getDownloadUrl(encodedKey)}" class="btn btn-success btn-sm"
|
|
718
|
+
<a href="#" onclick="event.preventDefault(); downloadFileViaFetch('${getDownloadUrl(encodedKey)}', '${escapeAttr(filename)}')" class="btn btn-success btn-sm"
|
|
690
719
|
title="Download file">
|
|
691
720
|
<i class="fas fa-download"></i>
|
|
692
721
|
</a>
|
|
@@ -1601,9 +1630,12 @@ function showPreviewModal(encodedKey, filename, extension, previewMode = 'auto')
|
|
|
1601
1630
|
titleEl.textContent = filename;
|
|
1602
1631
|
titleEl.title = filename;
|
|
1603
1632
|
openBtn.href = getViewUrl(encodedKey);
|
|
1604
|
-
downloadBtn.href =
|
|
1605
|
-
|
|
1606
|
-
|
|
1633
|
+
downloadBtn.href = '#';
|
|
1634
|
+
downloadBtn.onclick = (e) => {
|
|
1635
|
+
e.preventDefault();
|
|
1636
|
+
downloadFileViaFetch(getDownloadUrl(encodedKey), filename);
|
|
1637
|
+
};
|
|
1638
|
+
downloadBtn.removeAttribute('download');
|
|
1607
1639
|
|
|
1608
1640
|
// Reset sizing classes
|
|
1609
1641
|
dialog.classList.remove('preview-large', 'preview-compact');
|
package/src/app.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { engine } from "express-handlebars";
|
|
5
|
+
import compression from "compression";
|
|
6
|
+
import mbkautheRoutes from "mbkauthe";
|
|
7
|
+
import { generalLimiter } from "./middleware/rate-limiter.js";
|
|
8
|
+
import { notFoundHandler, globalErrorHandler } from "./middleware/error-handler.js";
|
|
9
|
+
import bucketRoutes from "./routes/index.js";
|
|
10
|
+
import { packageJson } from "./config/index.js";
|
|
11
|
+
import { createLogger } from "./utils/logger.js";
|
|
12
|
+
|
|
13
|
+
const debugServer = createLogger('server');
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
const isDevMode = process.env.NODE_ENV === "dev";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates and configures the Express application.
|
|
21
|
+
* @returns {express.Application}
|
|
22
|
+
*/
|
|
23
|
+
export function createApp() {
|
|
24
|
+
const server = express();
|
|
25
|
+
server.set('trust proxy', 1);
|
|
26
|
+
|
|
27
|
+
// Compression
|
|
28
|
+
server.use(compression());
|
|
29
|
+
|
|
30
|
+
// Body parsing
|
|
31
|
+
server.use(express.json({ limit: '10mb' }));
|
|
32
|
+
server.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
33
|
+
|
|
34
|
+
// Static assets (CSS/JS for dashboard)
|
|
35
|
+
const dashboardAssetCacheControl = 'public, max-age=300, must-revalidate';
|
|
36
|
+
const serveAsset = (filename) => (req, res) => {
|
|
37
|
+
res.set('Cache-Control', dashboardAssetCacheControl);
|
|
38
|
+
res.sendFile(path.join(__dirname, '..', 'public', filename), (err) => {
|
|
39
|
+
if (err) res.status(err.status || 404).end();
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
server.get('/mbkbucket/bucketadmin.css', serveAsset('bucketadmin.css'));
|
|
44
|
+
server.get('/mbkbucket/bucketadmin.js', serveAsset('bucketadmin.js'));
|
|
45
|
+
|
|
46
|
+
// Handlebars configuration
|
|
47
|
+
server.engine("handlebars", engine({
|
|
48
|
+
extname: ".handlebars",
|
|
49
|
+
defaultLayout: "main",
|
|
50
|
+
layoutsDir: path.join(__dirname, "..", "views", "layouts"),
|
|
51
|
+
partialsDir: [
|
|
52
|
+
path.join(__dirname, "..", "views", "templates"),
|
|
53
|
+
path.join(__dirname, "..", "views", "templates", "notice"),
|
|
54
|
+
path.join(__dirname, "..", "views"),
|
|
55
|
+
path.join(__dirname, "..", "views", "partial"),
|
|
56
|
+
path.join(__dirname, "..", "node_modules", "mbkauthe", "views"),
|
|
57
|
+
path.join(__dirname, "..", "..", "mbkauthe", "views")
|
|
58
|
+
],
|
|
59
|
+
cache: process.env.NODE_ENV === "production",
|
|
60
|
+
helpers: {
|
|
61
|
+
eq: function (a, b) {
|
|
62
|
+
return a === b;
|
|
63
|
+
},
|
|
64
|
+
mbkbucket_cachebuster: function () {
|
|
65
|
+
return "?v=" + packageJson.version;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
server.set("view engine", "handlebars");
|
|
71
|
+
server.set("views", [
|
|
72
|
+
path.join(__dirname, "..", "views"),
|
|
73
|
+
path.join(__dirname, "..", "node_modules", "mbkauthe", "views"),
|
|
74
|
+
path.join(__dirname, "..", "..", "mbkauthe", "views")
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// Auth routes
|
|
78
|
+
server.use(mbkautheRoutes);
|
|
79
|
+
|
|
80
|
+
// Rate limiting
|
|
81
|
+
server.use(generalLimiter);
|
|
82
|
+
|
|
83
|
+
// Bucket routes
|
|
84
|
+
server.use(bucketRoutes);
|
|
85
|
+
|
|
86
|
+
// Dev-mode request timing
|
|
87
|
+
if (isDevMode) {
|
|
88
|
+
debugServer("Dev mode is enabled. Starting server in dev mode.");
|
|
89
|
+
|
|
90
|
+
server.use((req, res, next) => {
|
|
91
|
+
const start = process.hrtime();
|
|
92
|
+
res.on('finish', () => {
|
|
93
|
+
const diff = process.hrtime(start);
|
|
94
|
+
const ms = diff[0] * 1000 + diff[1] / 1e6;
|
|
95
|
+
debugServer("[%s] %s %s %s - %s ms", new Date().toISOString(), req.method, req.originalUrl, res.statusCode, ms.toFixed(3));
|
|
96
|
+
});
|
|
97
|
+
next();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
server.get("/", async (req, res) => {
|
|
101
|
+
res.redirect("/mbkbucket");
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Error handlers
|
|
106
|
+
server.use(notFoundHandler);
|
|
107
|
+
server.use(globalErrorHandler);
|
|
108
|
+
|
|
109
|
+
return server;
|
|
110
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Authentication Helper
|
|
3
|
+
*
|
|
4
|
+
* Handles mbkauthe device-flow login for the mbkbucket CLI.
|
|
5
|
+
* Stores credentials in ~/.mbkbucket/config.json
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import { createLogger } from '../utils/logger.js';
|
|
12
|
+
|
|
13
|
+
const debugCli = createLogger('cli:auth');
|
|
14
|
+
|
|
15
|
+
export const CONFIG_DIR = path.join(os.homedir(), '.mbkbucket');
|
|
16
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
17
|
+
const POLL_INTERVAL_MS = 5000;
|
|
18
|
+
const MAX_POLL_TIME_MS = 15 * 60 * 1000; // 15 minutes
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read the stored CLI config.
|
|
22
|
+
* @returns {{ token?: string, tokenPrefix?: string, username?: string, serverUrl?: string, profile?: object }}
|
|
23
|
+
*/
|
|
24
|
+
export function readConfig() {
|
|
25
|
+
try {
|
|
26
|
+
if (!fs.existsSync(CONFIG_FILE)) return {};
|
|
27
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
debugCli('Failed to read config: %s', err.message);
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write config to disk.
|
|
37
|
+
* @param {object} config
|
|
38
|
+
*/
|
|
39
|
+
export function writeConfig(config) {
|
|
40
|
+
try {
|
|
41
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
42
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(`❌ Failed to write config: ${err.message}`);
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clear stored credentials (logout).
|
|
53
|
+
*/
|
|
54
|
+
export function clearConfig() {
|
|
55
|
+
try {
|
|
56
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
57
|
+
fs.unlinkSync(CONFIG_FILE);
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
debugCli('Failed to clear config: %s', err.message);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Merge partial config into stored config.
|
|
68
|
+
* @param {object} partial
|
|
69
|
+
*/
|
|
70
|
+
export function updateConfig(partial) {
|
|
71
|
+
const current = readConfig();
|
|
72
|
+
const updated = { ...current, ...partial };
|
|
73
|
+
writeConfig(updated);
|
|
74
|
+
return updated;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if the user is logged in (has a stored token).
|
|
79
|
+
*/
|
|
80
|
+
export function isLoggedIn() {
|
|
81
|
+
const cfg = readConfig();
|
|
82
|
+
return !!(cfg.token && cfg.serverUrl);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get the stored API token.
|
|
87
|
+
*/
|
|
88
|
+
export function getStoredToken() {
|
|
89
|
+
return readConfig().token || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get the stored server URL.
|
|
94
|
+
*/
|
|
95
|
+
export function getStoredServerUrl() {
|
|
96
|
+
return readConfig().serverUrl || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Perform a device-flow login against an mbkauthe-powered server.
|
|
101
|
+
*
|
|
102
|
+
* @param {{ serverUrl: string, profileKey?: string }} options
|
|
103
|
+
* @returns {Promise<{ token: string, tokenPrefix: string, username: string, profile: object }>}
|
|
104
|
+
*/
|
|
105
|
+
export async function deviceFlowLogin({ serverUrl, profileKey }) {
|
|
106
|
+
const baseUrl = String(serverUrl).replace(/\/+$/, '');
|
|
107
|
+
|
|
108
|
+
// --- Step 1: Request a device code ---
|
|
109
|
+
console.log(`🔑 Requesting device login from ${baseUrl} ...`);
|
|
110
|
+
|
|
111
|
+
let deviceResp;
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(`${baseUrl}/api/cli/device`, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
clientName: `mbkbucket-cli-${os.hostname()}`,
|
|
118
|
+
...(profileKey ? { profileKey } : {}),
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
deviceResp = await res.json();
|
|
123
|
+
|
|
124
|
+
if (!deviceResp.success) {
|
|
125
|
+
throw new Error(deviceResp.message || 'Failed to initiate device login');
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err.message.includes('Failed to initiate') || err.message.includes('fetch')) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Could not reach mbkauthe server at ${baseUrl}/api/cli/device.\n` +
|
|
131
|
+
`Verify the server URL and that CLI_AUTH_ENABLED=true on the server.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const {
|
|
138
|
+
verificationUrl,
|
|
139
|
+
userCode,
|
|
140
|
+
deviceCode,
|
|
141
|
+
expiresIn,
|
|
142
|
+
interval,
|
|
143
|
+
profile,
|
|
144
|
+
} = deviceResp;
|
|
145
|
+
|
|
146
|
+
// --- Step 2: Prompt user to approve ---
|
|
147
|
+
console.log('');
|
|
148
|
+
console.log('┌─────────────────────────────────────────────────────────┐');
|
|
149
|
+
console.log('│ │');
|
|
150
|
+
console.log(`│ 🌐 Open this URL in your browser to approve: │`);
|
|
151
|
+
console.log(`│ ${verificationUrl.padEnd(53)}│`);
|
|
152
|
+
console.log(`│ │`);
|
|
153
|
+
console.log(`│ 🔢 Or enter this code: ${userCode.padEnd(30)}│`);
|
|
154
|
+
console.log('│ │');
|
|
155
|
+
console.log('└─────────────────────────────────────────────────────────┘');
|
|
156
|
+
console.log('');
|
|
157
|
+
|
|
158
|
+
if (profile) {
|
|
159
|
+
console.log(` Profile: ${profile.name} (scope: ${profile.scope || 'full'})`);
|
|
160
|
+
}
|
|
161
|
+
console.log(` Waiting for approval (expires in ${Math.floor(expiresIn / 60)} minutes)...`);
|
|
162
|
+
console.log('');
|
|
163
|
+
|
|
164
|
+
// --- Step 3: Poll for token ---
|
|
165
|
+
const pollInterval = (interval || 5) * 1000;
|
|
166
|
+
const deadline = Date.now() + MAX_POLL_TIME_MS;
|
|
167
|
+
let attempts = 0;
|
|
168
|
+
|
|
169
|
+
while (Date.now() < deadline) {
|
|
170
|
+
await sleep(pollInterval);
|
|
171
|
+
attempts++;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const pollRes = await fetch(`${baseUrl}/api/cli/device/token`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({ deviceCode }),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const pollBody = await pollRes.json();
|
|
181
|
+
|
|
182
|
+
if (pollBody.status === 'approved' && pollBody.success) {
|
|
183
|
+
console.log(`✅ Login approved! Welcome, ${pollBody.username}.`);
|
|
184
|
+
console.log(` Token prefix: ${pollBody.tokenPrefix}...`);
|
|
185
|
+
return {
|
|
186
|
+
token: pollBody.token,
|
|
187
|
+
tokenPrefix: pollBody.tokenPrefix,
|
|
188
|
+
username: pollBody.username,
|
|
189
|
+
profile: profile || null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (pollBody.status === 'denied') {
|
|
194
|
+
throw new Error('Login request was denied.');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (pollBody.status === 'expired') {
|
|
198
|
+
throw new Error('Login request expired. Please try again.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (pollBody.status === 'completed') {
|
|
202
|
+
throw new Error('This login session has already been completed.');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Still pending — show a dot every 5 polls
|
|
206
|
+
if (attempts % 5 === 0) {
|
|
207
|
+
process.stdout.write('.');
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (err.message.includes('denied') || err.message.includes('expired') || err.message.includes('completed')) {
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
// Network errors during poll — retry
|
|
214
|
+
debugCli('Poll error (will retry): %s', err.message);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
throw new Error('Login timed out. Please try again.');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sleep(ms) {
|
|
222
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
223
|
+
}
|