lobsterboard 0.5.2 → 0.6.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.
@@ -1,4 +1,4 @@
1
- /* LobsterBoard v0.5.2 - Dashboard Styles */
1
+ /* LobsterBoard v0.6.0 - Dashboard Styles */
2
2
  /* LobsterBoard Dashboard - Generated Styles */
3
3
 
4
4
  :root {
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.5.2
2
+ * LobsterBoard v0.6.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.5.2
2
+ * LobsterBoard v0.6.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.5.2
2
+ * LobsterBoard v0.6.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.5.2
2
+ * LobsterBoard v0.6.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobsterboard",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Self-hosted drag-and-drop dashboard builder with 50 widgets, template gallery, and custom pages. Works standalone or with OpenClaw.",
5
5
  "keywords": [
6
6
  "dashboard",
package/server.cjs CHANGED
@@ -59,68 +59,95 @@ const HOST = process.env.HOST || '127.0.0.1';
59
59
  // ─────────────────────────────────────────────
60
60
  // Pages System — auto-discovery and mounting
61
61
  // ─────────────────────────────────────────────
62
- const PAGES_DIR = path.join(__dirname, 'pages');
63
- const PAGES_JSON = path.join(__dirname, 'pages.json');
64
- const DATA_DIR = path.join(__dirname, 'data');
62
+ // Load from both package pages AND user's working directory pages (user overrides package)
63
+ const CWD = process.cwd();
64
+ const USER_PAGES_DIR = path.join(CWD, 'pages');
65
+ const PKG_PAGES_DIR = path.join(__dirname, 'pages');
66
+ const PAGES_DIRS = [PKG_PAGES_DIR]; // Package pages first
67
+ if (CWD !== __dirname && fs.existsSync(USER_PAGES_DIR)) {
68
+ PAGES_DIRS.push(USER_PAGES_DIR); // User pages override
69
+ }
70
+ // For backwards compat, PAGES_DIR points to first available (used for _shared)
71
+ const PAGES_DIR = fs.existsSync(USER_PAGES_DIR) ? USER_PAGES_DIR : PKG_PAGES_DIR;
72
+ const USER_PAGES_JSON = path.join(CWD, 'pages.json');
73
+ const PKG_PAGES_JSON = path.join(__dirname, 'pages.json');
74
+ const PAGES_JSON = fs.existsSync(USER_PAGES_JSON) ? USER_PAGES_JSON : PKG_PAGES_JSON;
75
+ // Data always in working directory (user's data)
76
+ const DATA_DIR = path.join(CWD, 'data');
65
77
 
66
78
  let loadedPages = []; // { id, title, icon, description, order, routes: { 'METHOD /path': handler } }
67
79
 
68
80
  function loadPages() {
69
81
  const pages = [];
82
+ const seenIds = new Set();
70
83
  let overrides = { pages: {} };
71
84
  try { overrides = JSON.parse(fs.readFileSync(PAGES_JSON, 'utf8')); } catch (_) {}
72
85
 
73
- let dirs;
74
- try { dirs = fs.readdirSync(PAGES_DIR); } catch (_) { return pages; }
86
+ // Scan all page directories (user pages loaded last to override package pages)
87
+ for (const pagesDir of PAGES_DIRS) {
88
+ let dirs;
89
+ try { dirs = fs.readdirSync(pagesDir); } catch (_) { continue; }
75
90
 
76
- for (const dir of dirs) {
77
- if (dir.startsWith('_')) continue;
78
- const metaPath = path.join(PAGES_DIR, dir, 'page.json');
79
- if (!fs.existsSync(metaPath)) continue;
91
+ for (const dir of dirs) {
92
+ if (dir.startsWith('_')) continue;
93
+ const metaPath = path.join(pagesDir, dir, 'page.json');
94
+ if (!fs.existsSync(metaPath)) continue;
80
95
 
81
- let meta;
82
- try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch (_) { continue; }
96
+ let meta;
97
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch (_) { continue; }
83
98
 
84
- const override = overrides.pages[meta.id] || {};
85
- meta.enabled = override.enabled ?? meta.enabled ?? true;
86
- meta.order = override.order ?? meta.order ?? 99;
99
+ const override = overrides.pages[meta.id] || {};
100
+ meta.enabled = override.enabled ?? meta.enabled ?? true;
101
+ meta.order = override.order ?? meta.order ?? 99;
87
102
 
88
- if (!meta.enabled) continue;
103
+ if (!meta.enabled) continue;
89
104
 
90
- // Ensure data dir
91
- const dataDir = path.join(DATA_DIR, meta.id);
92
- fs.mkdirSync(dataDir, { recursive: true });
105
+ // If we've seen this ID before (from package), remove it so user page wins
106
+ if (seenIds.has(meta.id)) {
107
+ const idx = pages.findIndex(p => p.id === meta.id);
108
+ if (idx !== -1) pages.splice(idx, 1);
109
+ }
110
+ seenIds.add(meta.id);
93
111
 
94
- // Load API routes if api.cjs (or api.js) exists
95
- let apiPath = path.join(PAGES_DIR, dir, 'api.cjs');
96
- if (!fs.existsSync(apiPath)) apiPath = path.join(PAGES_DIR, dir, 'api.js');
97
- let routes = {};
98
- if (fs.existsSync(apiPath)) {
99
- try {
100
- const ctx = {
101
- dataDir,
102
- readData: (filename) => JSON.parse(fs.readFileSync(path.join(dataDir, filename), 'utf8')),
103
- writeData: (filename, obj) => {
104
- fs.mkdirSync(dataDir, { recursive: true });
105
- fs.writeFileSync(path.join(dataDir, filename), JSON.stringify(obj, null, 2));
106
- }
107
- };
108
- const pageModule = require(apiPath)(ctx);
109
- routes = pageModule.routes || {};
110
- } catch (e) {
111
- console.error(`Error loading page API for ${meta.id}:`, e.message);
112
+ // Store which directory this page came from
113
+ meta._pagesDir = pagesDir;
114
+
115
+ // Ensure data dir
116
+ const dataDir = path.join(DATA_DIR, meta.id);
117
+ fs.mkdirSync(dataDir, { recursive: true });
118
+
119
+ // Load API routes if api.cjs (or api.js) exists
120
+ let apiPath = path.join(pagesDir, dir, 'api.cjs');
121
+ if (!fs.existsSync(apiPath)) apiPath = path.join(pagesDir, dir, 'api.js');
122
+ let routes = {};
123
+ if (fs.existsSync(apiPath)) {
124
+ try {
125
+ const ctx = {
126
+ dataDir,
127
+ readData: (filename) => JSON.parse(fs.readFileSync(path.join(dataDir, filename), 'utf8')),
128
+ writeData: (filename, obj) => {
129
+ fs.mkdirSync(dataDir, { recursive: true });
130
+ fs.writeFileSync(path.join(dataDir, filename), JSON.stringify(obj, null, 2));
131
+ }
132
+ };
133
+ const pageModule = require(apiPath)(ctx);
134
+ routes = pageModule.routes || {};
135
+ } catch (e) {
136
+ console.error(`Error loading page API for ${meta.id}:`, e.message);
137
+ }
112
138
  }
113
- }
114
139
 
115
- pages.push({
116
- id: meta.id,
117
- title: meta.title,
118
- icon: meta.icon,
119
- description: meta.description,
120
- order: meta.order,
121
- nav: meta.nav !== false,
122
- routes
123
- });
140
+ pages.push({
141
+ id: meta.id,
142
+ title: meta.title,
143
+ icon: meta.icon,
144
+ description: meta.description,
145
+ order: meta.order,
146
+ nav: meta.nav !== false,
147
+ _pagesDir: pagesDir,
148
+ routes
149
+ });
150
+ }
124
151
  }
125
152
 
126
153
  return pages.sort((a, b) => a.order - b.order);
@@ -173,11 +200,16 @@ function matchPageRoute(pages, method, pathname, parsedUrl) {
173
200
  }
174
201
  const page = pages.find(p => p.id === pageId);
175
202
  if (page) {
176
- const subPath = pagesMatch[2] || '/';
177
- if (subPath === '/' || subPath === '') {
178
- return { type: 'static', filePath: path.join(PAGES_DIR, pageId, 'index.html') };
203
+ const subPath = pagesMatch[2] || '';
204
+ // Redirect /pages/id to /pages/id/ (trailing slash) for proper relative path resolution
205
+ if (!subPath) {
206
+ return { type: 'redirect', location: `/pages/${pageId}/` };
207
+ }
208
+ const pageDir = page._pagesDir || PAGES_DIR;
209
+ if (subPath === '/') {
210
+ return { type: 'static', filePath: path.join(pageDir, pageId, 'index.html') };
179
211
  }
180
- return { type: 'static', filePath: path.join(PAGES_DIR, pageId, subPath.slice(1)) };
212
+ return { type: 'static', filePath: path.join(pageDir, pageId, subPath.slice(1)) };
181
213
  }
182
214
  }
183
215
 
@@ -2329,9 +2361,16 @@ const server = http.createServer(async (req, res) => {
2329
2361
  sendJson(res, 200, loadedPages.filter(p => p.nav !== false).map(p => ({ id: p.id, title: p.title, icon: p.icon, description: p.description, order: p.order })));
2330
2362
  return;
2331
2363
  }
2364
+ if (pageMatch.type === 'redirect') {
2365
+ res.writeHead(302, { Location: pageMatch.location });
2366
+ res.end();
2367
+ return;
2368
+ }
2332
2369
  if (pageMatch.type === 'static') {
2333
2370
  const resolved = path.resolve(pageMatch.filePath);
2334
- if (!resolved.startsWith(path.resolve(PAGES_DIR))) {
2371
+ // Security: ensure path is within one of the allowed page directories
2372
+ const isAllowed = PAGES_DIRS.some(dir => resolved.startsWith(path.resolve(dir)));
2373
+ if (!isAllowed) {
2335
2374
  sendResponse(res, 403, 'text/plain', 'Forbidden');
2336
2375
  return;
2337
2376
  }
@@ -3370,6 +3409,19 @@ const server = http.createServer(async (req, res) => {
3370
3409
  }
3371
3410
 
3372
3411
  // Serve static files
3412
+ // Check working directory's public/ folder first (for user assets like recap images)
3413
+ const publicPath = path.join(CWD, 'public', pathname);
3414
+ const publicResolved = path.resolve(publicPath);
3415
+ if (publicResolved.startsWith(path.join(CWD, 'public') + path.sep) && fs.existsSync(publicPath)) {
3416
+ const ext = path.extname(publicPath).toLowerCase();
3417
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
3418
+ fs.readFile(publicPath, (err, data) => {
3419
+ if (err) { sendError(res, err.message); return; }
3420
+ sendResponse(res, 200, contentType, data);
3421
+ });
3422
+ return;
3423
+ }
3424
+
3373
3425
  let filePath = path.join(__dirname, pathname);
3374
3426
  if (pathname === '/') {
3375
3427
  filePath = path.join(__dirname, 'app.html');