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 +24 -0
- package/bin/greend-server.js +2 -0
- package/data/.gitkeep +0 -0
- package/package.json +40 -0
- package/server.js +357 -0
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
|
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
|
+
});
|