greend-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,24 @@
1
+ # GreenD Server Configuration
2
+ # Copy this file to .env and fill in your values
3
+
4
+ # Port and host to listen on
5
+ PORT=3001
6
+ HOST=0.0.0.0
7
+
8
+ # Directory where all ESG data, user accounts, and audit logs are stored.
9
+ # Use an absolute path outside the npm package so data survives upgrades.
10
+ # Example: DATA_DIR=/opt/greend/data
11
+ DATA_DIR=./data
12
+
13
+ # REQUIRED in production: set this to a long random string (32+ chars).
14
+ # Never use the default in production.
15
+ SESSION_SECRET=change-this-to-a-long-random-string
16
+
17
+ # Origins allowed to make cross-origin requests (comma-separated).
18
+ # The GreenD desktop app connects from app://localhost.
19
+ # If you also access GreenD from a browser, add that origin too.
20
+ # Example: CORS_ORIGIN=app://localhost,https://greend.yourcompany.com
21
+ CORS_ORIGIN=app://localhost
22
+
23
+ # Set to 'production' to enable secure cookie flags.
24
+ NODE_ENV=production
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import('../server.js');
package/data/.gitkeep ADDED
File without changes
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "greend-server",
3
+ "version": "1.0.0",
4
+ "description": "GreenD ESG backend — self-hosted Express.js API server",
5
+ "type": "module",
6
+ "main": "server.js",
7
+ "author": "henryfan <henryfan1104@gmail.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/Henry-fan88/greend-server.git"
11
+ },
12
+ "bugs": { "url": "https://github.com/Henry-fan88/greend-server/issues" },
13
+ "homepage": "https://github.com/Henry-fan88/greend-server#readme",
14
+ "bin": {
15
+ "greend-server": "./bin/greend-server.js"
16
+ },
17
+ "scripts": {
18
+ "start": "node server.js",
19
+ "dev": "node --watch server.js"
20
+ },
21
+ "dependencies": {
22
+ "bcryptjs": "^3.0.3",
23
+ "cors": "^2.8.5",
24
+ "dotenv": "^17.2.3",
25
+ "express": "^4.21.2",
26
+ "express-session": "^1.19.0"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "keywords": ["esg", "sustainability", "enterprise", "on-premise"],
32
+ "license": "ISC",
33
+ "files": [
34
+ "server.js",
35
+ "bin/",
36
+ "data/.gitkeep",
37
+ ".env.example"
38
+ ],
39
+ "publishConfig": { "access": "public" }
40
+ }
package/server.js ADDED
@@ -0,0 +1,357 @@
1
+ import 'dotenv/config';
2
+ import { randomBytes } from 'crypto';
3
+ import express from 'express';
4
+ import cors from 'cors';
5
+ import { readFile, writeFile, mkdir, readdir } from 'fs/promises';
6
+ import { fileURLToPath } from 'url';
7
+ import { dirname, join } from 'path';
8
+ import path from 'path';
9
+ import { createRequire } from 'module';
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const bcrypt = require('bcryptjs');
13
+ const session = require('express-session');
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+
17
+ // Production guard: refuse to start if SESSION_SECRET is missing or still set to the dev placeholder.
18
+ if (
19
+ !process.env.SESSION_SECRET ||
20
+ process.env.SESSION_SECRET === 'greend-dev-secret-change-in-production' ||
21
+ process.env.SESSION_SECRET === 'change-this-to-a-long-random-string'
22
+ ) {
23
+ console.error('');
24
+ console.error('ERROR: SESSION_SECRET is not set or is using the default development value.');
25
+ console.error('Set a strong, unique SESSION_SECRET (32+ random characters) in your .env file.');
26
+ console.error('Example: SESSION_SECRET=' + randomBytes(32).toString('hex'));
27
+ console.error('');
28
+ process.exit(1);
29
+ }
30
+
31
+ const DATA_DIR = process.env.DATA_DIR
32
+ ? path.resolve(process.env.DATA_DIR)
33
+ : join(__dirname, 'data');
34
+
35
+ const app = express();
36
+ app.use(express.json({ limit: '50mb' }));
37
+
38
+ // CORS — allow the Electron desktop app (app://localhost) and any custom origins
39
+ const ALLOWED_ORIGINS = (process.env.CORS_ORIGIN || 'app://localhost')
40
+ .split(',').map(s => s.trim());
41
+
42
+ app.use(cors({
43
+ origin: (origin, callback) => {
44
+ if (!origin || ALLOWED_ORIGINS.includes(origin)) return callback(null, true);
45
+ callback(new Error(`CORS blocked: ${origin}`));
46
+ },
47
+ credentials: true,
48
+ }));
49
+
50
+ app.use(session({
51
+ secret: process.env.SESSION_SECRET || 'greend-dev-secret-change-in-production',
52
+ resave: false,
53
+ saveUninitialized: false,
54
+ cookie: {
55
+ httpOnly: true,
56
+ secure: process.env.NODE_ENV === 'production',
57
+ sameSite: 'lax',
58
+ maxAge: 8 * 60 * 60 * 1000, // 8 hours
59
+ },
60
+ }));
61
+
62
+ // Generic helpers for simple single-file read/write
63
+ async function readJson(relPath, fallback) {
64
+ try {
65
+ return JSON.parse(await readFile(join(DATA_DIR, relPath), 'utf8'));
66
+ } catch {
67
+ return fallback;
68
+ }
69
+ }
70
+
71
+ async function writeJson(relPath, payload) {
72
+ const fullPath = join(DATA_DIR, relPath);
73
+ await mkdir(dirname(fullPath), { recursive: true });
74
+ await writeFile(fullPath, JSON.stringify(payload, null, 2));
75
+ }
76
+
77
+ // User profile helpers
78
+ async function readUsers() {
79
+ return readJson('user_profile/users.json', []);
80
+ }
81
+
82
+ async function writeUsers(users) {
83
+ await writeJson('user_profile/users.json', users);
84
+ }
85
+
86
+ // ESG data: read all esg/{year}.json files and merge into a single EsgData[] array
87
+ async function readEsgData() {
88
+ let files;
89
+ try {
90
+ files = (await readdir(join(DATA_DIR, 'esg'))).filter(f => f.endsWith('.json'));
91
+ } catch {
92
+ return [];
93
+ }
94
+ if (files.length === 0) return [];
95
+
96
+ const allYearData = await Promise.all(
97
+ files.sort().map(f =>
98
+ readFile(join(DATA_DIR, 'esg', f), 'utf8').then(JSON.parse)
99
+ )
100
+ );
101
+
102
+ // Merge records by id — shared metadata takes the latest value;
103
+ // yearlyValues and lastModifiedBy are accumulated across all year files.
104
+ const byId = new Map();
105
+ for (const yearRecords of allYearData) {
106
+ for (const record of yearRecords) {
107
+ if (!byId.has(record.id)) {
108
+ byId.set(record.id, { ...record, yearlyValues: {}, lastModifiedBy: [] });
109
+ }
110
+ const m = byId.get(record.id);
111
+ // Shared metadata — all year files should carry the same values; last file wins
112
+ m.parentId = record.parentId;
113
+ m.category = record.category;
114
+ m.section = record.section;
115
+ m.scope = record.scope;
116
+ m.unit = record.unit;
117
+ m.department = record.department;
118
+ // Year-specific data
119
+ Object.assign(m.yearlyValues, record.yearlyValues);
120
+ m.lastModifiedBy.push(...record.lastModifiedBy);
121
+ }
122
+ }
123
+
124
+ return Array.from(byId.values());
125
+ }
126
+
127
+ // ESG data: split a full EsgData[] array and write one file per year
128
+ // NOTE: JSON file writes are not atomic. Concurrent saves from multiple desktop users
129
+ // can corrupt data. This storage layer is designed for single-user or low-concurrency
130
+ // deployments. For multi-user environments with simultaneous edits, migrate to SQLite.
131
+ async function writeEsgData(records) {
132
+ await mkdir(join(DATA_DIR, 'esg'), { recursive: true });
133
+
134
+ // Collect every year referenced in values or change history
135
+ const years = new Set();
136
+ for (const r of records) {
137
+ for (const y of Object.keys(r.yearlyValues)) years.add(Number(y));
138
+ for (const c of r.lastModifiedBy) years.add(c.year);
139
+ }
140
+
141
+ for (const year of [...years].sort()) {
142
+ const yearRecords = records
143
+ .filter(r => year in r.yearlyValues || r.lastModifiedBy.some(c => c.year === year))
144
+ .map(r => ({
145
+ ...r,
146
+ yearlyValues: year in r.yearlyValues ? { [year]: r.yearlyValues[year] } : {},
147
+ lastModifiedBy: r.lastModifiedBy.filter(c => c.year === year),
148
+ }));
149
+ await writeFile(
150
+ join(DATA_DIR, 'esg', `${year}.json`),
151
+ JSON.stringify(yearRecords, null, 2)
152
+ );
153
+ }
154
+ }
155
+
156
+ const DEFAULT_PREFS = {
157
+ dashboardLayout: [
158
+ { i: 'emissions', x: 0, y: 0, w: 6, h: 4 },
159
+ { i: 'energy', x: 6, y: 0, w: 6, h: 4 },
160
+ { i: 'diversity', x: 0, y: 4, w: 6, h: 4 },
161
+ { i: 'summary', x: 6, y: 4, w: 6, h: 4 },
162
+ ],
163
+ dashboardPanels: ['emissions', 'energy', 'diversity', 'summary'],
164
+ customPanelConfigs: {},
165
+ };
166
+
167
+ // Auth middleware
168
+ function requireAuth(req, res, next) {
169
+ if (req.session?.userId) return next();
170
+ res.status(401).json({ error: 'Unauthorized' });
171
+ }
172
+
173
+ function requireAdmin(req, res, next) {
174
+ if (!req.session?.userId) return res.status(401).json({ error: 'Unauthorized' });
175
+ readUsers().then(users => {
176
+ const user = users.find(u => u.id === req.session.userId);
177
+ if (!user || user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
178
+ next();
179
+ }).catch(() => res.status(500).json({ error: 'Internal server error' }));
180
+ }
181
+
182
+ // Auth endpoints
183
+ app.post('/api/auth/login', async (req, res) => {
184
+ const { username, password } = req.body;
185
+ if (!username || !password) return res.status(400).json({ error: 'Missing credentials' });
186
+
187
+ const users = await readUsers();
188
+ const user = users.find(u => u.username === username.toLowerCase().trim());
189
+ // Same error for missing user and wrong password to prevent username enumeration
190
+ if (!user) return res.status(401).json({ error: 'Invalid username or password' });
191
+
192
+ const valid = await bcrypt.compare(password, user.passwordHash);
193
+ if (!valid) return res.status(401).json({ error: 'Invalid username or password' });
194
+
195
+ req.session.userId = user.id;
196
+ const { passwordHash, ...safeUser } = user;
197
+ res.json(safeUser);
198
+ });
199
+
200
+ app.post('/api/auth/logout', (req, res) => {
201
+ req.session.destroy(() => res.sendStatus(204));
202
+ });
203
+
204
+ app.put('/api/auth/password', requireAuth, async (req, res) => {
205
+ const { currentPassword, newPassword } = req.body;
206
+ if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Missing fields' });
207
+ if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });
208
+
209
+ const users = await readUsers();
210
+ const idx = users.findIndex(u => u.id === req.session.userId);
211
+ if (idx === -1) return res.status(401).json({ error: 'Session invalid' });
212
+
213
+ const valid = await bcrypt.compare(currentPassword, users[idx].passwordHash);
214
+ if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
215
+
216
+ users[idx].passwordHash = await bcrypt.hash(newPassword, 12);
217
+ await writeUsers(users);
218
+ res.sendStatus(204);
219
+ });
220
+
221
+ app.get('/api/auth/me', requireAuth, async (req, res) => {
222
+ const users = await readUsers();
223
+ const user = users.find(u => u.id === req.session.userId);
224
+ if (!user) return res.status(401).json({ error: 'Session invalid' });
225
+ const { passwordHash, ...safeUser } = user;
226
+ res.json(safeUser);
227
+ });
228
+
229
+ // User management endpoints (admin only)
230
+ app.get('/api/users', requireAdmin, async (req, res) => {
231
+ const users = await readUsers();
232
+ res.json(users.map(({ passwordHash, ...u }) => u));
233
+ });
234
+
235
+ app.post('/api/users', requireAdmin, async (req, res) => {
236
+ const { name, username, password, role, department } = req.body;
237
+ if (!name || !username || !password || !role || !department)
238
+ return res.status(400).json({ error: 'Missing required fields' });
239
+
240
+ const users = await readUsers();
241
+ if (users.find(u => u.username === username.toLowerCase().trim()))
242
+ return res.status(409).json({ error: 'Username already exists' });
243
+
244
+ const newUser = {
245
+ id: `u${Date.now()}`,
246
+ name: name.trim(),
247
+ username: username.toLowerCase().trim(),
248
+ passwordHash: await bcrypt.hash(password, 12),
249
+ role,
250
+ department,
251
+ createdAt: new Date().toISOString(),
252
+ };
253
+ await writeUsers([...users, newUser]);
254
+ const { passwordHash, ...safeUser } = newUser;
255
+ res.status(201).json(safeUser);
256
+ });
257
+
258
+ app.put('/api/users/:id', requireAdmin, async (req, res) => {
259
+ const users = await readUsers();
260
+ const idx = users.findIndex(u => u.id === req.params.id);
261
+ if (idx === -1) return res.status(404).json({ error: 'User not found' });
262
+
263
+ const { name, username, password, role, department } = req.body;
264
+ const updated = { ...users[idx] };
265
+ if (name) updated.name = name.trim();
266
+ if (username) updated.username = username.toLowerCase().trim();
267
+ if (role) updated.role = role;
268
+ if (department) updated.department = department;
269
+ if (password) updated.passwordHash = await bcrypt.hash(password, 12);
270
+
271
+ const conflict = users.find((u, i) => i !== idx && u.username === updated.username);
272
+ if (conflict) return res.status(409).json({ error: 'Username already exists' });
273
+
274
+ users[idx] = updated;
275
+ await writeUsers(users);
276
+ const { passwordHash, ...safeUser } = updated;
277
+ res.json(safeUser);
278
+ });
279
+
280
+ app.delete('/api/users/:id', requireAdmin, async (req, res) => {
281
+ if (req.params.id === req.session.userId)
282
+ return res.status(400).json({ error: 'Cannot delete your own account' });
283
+ const users = await readUsers();
284
+ const filtered = users.filter(u => u.id !== req.params.id);
285
+ if (filtered.length === users.length) return res.status(404).json({ error: 'User not found' });
286
+ await writeUsers(filtered);
287
+ res.sendStatus(204);
288
+ });
289
+
290
+ // Theme
291
+ app.get('/api/theme', requireAuth, async (req, res) => {
292
+ res.json(await readJson('settings/theme.json', { font: 'Inter' }));
293
+ });
294
+ app.put('/api/theme', requireAuth, async (req, res) => {
295
+ await writeJson('settings/theme.json', req.body);
296
+ res.sendStatus(204);
297
+ });
298
+
299
+ // ESG Data
300
+ app.get('/api/data', requireAuth, async (req, res) => {
301
+ res.json(await readEsgData());
302
+ });
303
+ app.put('/api/data', requireAuth, async (req, res) => {
304
+ await writeEsgData(req.body);
305
+ res.sendStatus(204);
306
+ });
307
+
308
+ // Audit Logs
309
+ app.get('/api/logs', requireAuth, async (req, res) => {
310
+ res.json(await readJson('logs/audit.json', []));
311
+ });
312
+ app.put('/api/logs', requireAuth, async (req, res) => {
313
+ await writeJson('logs/audit.json', req.body);
314
+ res.sendStatus(204);
315
+ });
316
+
317
+ // Per-user Dashboard Preferences
318
+ app.get('/api/prefs/:userId', requireAuth, async (req, res) => {
319
+ res.json(await readJson(`preferences/${req.params.userId}.json`, DEFAULT_PREFS));
320
+ });
321
+ app.put('/api/prefs/:userId', requireAuth, async (req, res) => {
322
+ await writeJson(`preferences/${req.params.userId}.json`, req.body);
323
+ res.sendStatus(204);
324
+ });
325
+
326
+ // Seed default admin account on first run
327
+ async function seedDefaultAdmin() {
328
+ const users = await readUsers();
329
+ if (users.length === 0) {
330
+ const passwordHash = await bcrypt.hash('admin123', 12);
331
+ await writeUsers([{
332
+ id: 'u1',
333
+ name: 'Admin',
334
+ username: 'admin',
335
+ passwordHash,
336
+ role: 'admin',
337
+ department: 'Sustainability',
338
+ createdAt: new Date().toISOString(),
339
+ }]);
340
+ console.log('');
341
+ console.log('⚠ Default admin account created.');
342
+ console.log(' Username : admin');
343
+ console.log(' Password : admin123');
344
+ console.log(' ACTION REQUIRED: Change this password immediately after first login.');
345
+ console.log(' (Settings → Change Password)');
346
+ console.log('');
347
+ }
348
+ }
349
+
350
+ const PORT = Number(process.env.PORT) || 3001;
351
+ const HOST = process.env.HOST || '0.0.0.0';
352
+
353
+ seedDefaultAdmin().then(() => {
354
+ app.listen(PORT, HOST, () =>
355
+ console.log(`GreenD server running on http://${HOST}:${PORT}`)
356
+ );
357
+ });