greend-server 1.0.2 → 1.0.4
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 +10 -4
- package/package.json +1 -1
- package/server.js +132 -16
package/.env.example
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
PORT=3001
|
|
6
6
|
HOST=0.0.0.0
|
|
7
7
|
|
|
8
|
-
# Directory where all ESG data, user accounts,
|
|
9
|
-
# Use an absolute path outside the
|
|
10
|
-
#
|
|
11
|
-
|
|
8
|
+
# Directory where all ESG data, user accounts, audit logs, and sessions are stored.
|
|
9
|
+
# Use an absolute path outside the app/package directory so data survives upgrades.
|
|
10
|
+
# If omitted, GreenD defaults to an OS-managed shared data location:
|
|
11
|
+
# Windows: %PROGRAMDATA%\GreenD\data
|
|
12
|
+
# macOS: ~/Library/Application Support/GreenD/data
|
|
13
|
+
# Linux: ~/.local/share/GreenD/data
|
|
14
|
+
# Example:
|
|
15
|
+
# DATA_DIR=C:\ProgramData\GreenD\data
|
|
16
|
+
# DATA_DIR=/opt/greend/data
|
|
17
|
+
# DATA_DIR=
|
|
12
18
|
|
|
13
19
|
# REQUIRED in production: set this to a long random string (32+ chars).
|
|
14
20
|
# Never use the default in production.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -2,10 +2,12 @@ import 'dotenv/config';
|
|
|
2
2
|
import { randomBytes } from 'crypto';
|
|
3
3
|
import express from 'express';
|
|
4
4
|
import cors from 'cors';
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
5
6
|
import { readFile, writeFile, mkdir, readdir } from 'fs/promises';
|
|
6
7
|
import { fileURLToPath } from 'url';
|
|
7
8
|
import { dirname, join } from 'path';
|
|
8
9
|
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
9
11
|
import { createRequire } from 'module';
|
|
10
12
|
|
|
11
13
|
const require = createRequire(import.meta.url);
|
|
@@ -28,9 +30,94 @@ if (
|
|
|
28
30
|
process.exit(1);
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
function getDefaultDataDir() {
|
|
34
|
+
if (process.platform === 'win32') {
|
|
35
|
+
const programData = process.env.PROGRAMDATA || path.join(process.env.SystemDrive || 'C:', 'ProgramData');
|
|
36
|
+
return path.join(programData, 'GreenD', 'data');
|
|
37
|
+
}
|
|
38
|
+
if (process.platform === 'darwin') {
|
|
39
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'GreenD', 'data');
|
|
40
|
+
}
|
|
41
|
+
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
42
|
+
return path.join(xdgDataHome, 'GreenD', 'data');
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
const DATA_DIR = process.env.DATA_DIR
|
|
32
46
|
? path.resolve(process.env.DATA_DIR)
|
|
33
|
-
:
|
|
47
|
+
: getDefaultDataDir();
|
|
48
|
+
|
|
49
|
+
const SESSION_COOKIE_NAME = 'connect.sid';
|
|
50
|
+
const SESSION_COOKIE_OPTIONS = {
|
|
51
|
+
httpOnly: true,
|
|
52
|
+
secure: process.env.NODE_ENV === 'production',
|
|
53
|
+
sameSite: 'lax',
|
|
54
|
+
path: '/',
|
|
55
|
+
maxAge: 8 * 60 * 60 * 1000,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const CLEAR_SESSION_COOKIE_OPTIONS = {
|
|
59
|
+
httpOnly: SESSION_COOKIE_OPTIONS.httpOnly,
|
|
60
|
+
secure: SESSION_COOKIE_OPTIONS.secure,
|
|
61
|
+
sameSite: SESSION_COOKIE_OPTIONS.sameSite,
|
|
62
|
+
path: SESSION_COOKIE_OPTIONS.path,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// File-backed session store — persists sessions to DATA_DIR/sessions.json so
|
|
67
|
+
// that sessions survive server restarts (MemoryStore loses them on every restart).
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
class FileSessionStore extends session.Store {
|
|
70
|
+
#filepath;
|
|
71
|
+
#sessions;
|
|
72
|
+
|
|
73
|
+
constructor(filepath) {
|
|
74
|
+
super();
|
|
75
|
+
this.#filepath = filepath;
|
|
76
|
+
this.#sessions = {};
|
|
77
|
+
// Load synchronously at startup so sessions are available immediately.
|
|
78
|
+
try {
|
|
79
|
+
const raw = readFileSync(filepath, 'utf8');
|
|
80
|
+
const loaded = JSON.parse(raw);
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
// Discard any already-expired sessions while loading.
|
|
83
|
+
for (const [sid, s] of Object.entries(loaded)) {
|
|
84
|
+
if (s?.cookie?.expires && new Date(s.cookie.expires).getTime() < now) continue;
|
|
85
|
+
this.#sessions[sid] = s;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// File doesn't exist yet — start with empty sessions.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#save() {
|
|
93
|
+
mkdir(dirname(this.#filepath), { recursive: true })
|
|
94
|
+
.then(() => writeFile(this.#filepath, JSON.stringify(this.#sessions)))
|
|
95
|
+
.catch(e => console.error('[sessions] write error:', e.message));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get(sid, cb) {
|
|
99
|
+
const s = this.#sessions[sid];
|
|
100
|
+
if (!s) return cb(null, null);
|
|
101
|
+
if (s.cookie?.expires && new Date(s.cookie.expires).getTime() < Date.now()) {
|
|
102
|
+
delete this.#sessions[sid];
|
|
103
|
+
this.#save();
|
|
104
|
+
return cb(null, null);
|
|
105
|
+
}
|
|
106
|
+
cb(null, s);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
set(sid, s, cb) {
|
|
110
|
+
this.#sessions[sid] = s;
|
|
111
|
+
this.#save();
|
|
112
|
+
cb(null);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
destroy(sid, cb) {
|
|
116
|
+
delete this.#sessions[sid];
|
|
117
|
+
this.#save();
|
|
118
|
+
cb(null);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
34
121
|
|
|
35
122
|
const app = express();
|
|
36
123
|
app.use(express.json({ limit: '50mb' }));
|
|
@@ -48,15 +135,11 @@ app.use(cors({
|
|
|
48
135
|
}));
|
|
49
136
|
|
|
50
137
|
app.use(session({
|
|
138
|
+
store: new FileSessionStore(join(DATA_DIR, 'sessions.json')),
|
|
51
139
|
secret: process.env.SESSION_SECRET || 'greend-dev-secret-change-in-production',
|
|
52
140
|
resave: false,
|
|
53
141
|
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
|
-
},
|
|
142
|
+
cookie: SESSION_COOKIE_OPTIONS,
|
|
60
143
|
}));
|
|
61
144
|
|
|
62
145
|
// Generic helpers for simple single-file read/write
|
|
@@ -201,13 +284,35 @@ app.post('/api/auth/login', async (req, res) => {
|
|
|
201
284
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
|
202
285
|
if (!valid) return res.status(401).json({ error: 'Invalid username or password' });
|
|
203
286
|
|
|
204
|
-
req.session.userId = user.id;
|
|
205
287
|
const { passwordHash, ...safeUser } = user;
|
|
206
|
-
|
|
288
|
+
req.session.regenerate((err) => {
|
|
289
|
+
if (err) {
|
|
290
|
+
console.error('[auth] failed to regenerate session:', err.message);
|
|
291
|
+
return res.status(500).json({ error: 'Failed to start a new session' });
|
|
292
|
+
}
|
|
293
|
+
req.session.userId = user.id;
|
|
294
|
+
res.json(safeUser);
|
|
295
|
+
});
|
|
207
296
|
});
|
|
208
297
|
|
|
209
298
|
app.post('/api/auth/logout', (req, res) => {
|
|
210
|
-
|
|
299
|
+
const finalize = () => {
|
|
300
|
+
res.clearCookie(SESSION_COOKIE_NAME, CLEAR_SESSION_COOKIE_OPTIONS);
|
|
301
|
+
res.sendStatus(204);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
if (!req.session) {
|
|
305
|
+
finalize();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
req.session.destroy((err) => {
|
|
310
|
+
if (err) {
|
|
311
|
+
console.error('[auth] failed to destroy session:', err.message);
|
|
312
|
+
return res.status(500).json({ error: 'Failed to close the session' });
|
|
313
|
+
}
|
|
314
|
+
finalize();
|
|
315
|
+
});
|
|
211
316
|
});
|
|
212
317
|
|
|
213
318
|
app.put('/api/auth/password', requireAuth, async (req, res) => {
|
|
@@ -230,7 +335,11 @@ app.put('/api/auth/password', requireAuth, async (req, res) => {
|
|
|
230
335
|
app.get('/api/auth/me', requireAuth, async (req, res) => {
|
|
231
336
|
const users = await readUsers();
|
|
232
337
|
const user = users.find(u => u.id === req.session.userId);
|
|
233
|
-
if (!user)
|
|
338
|
+
if (!user) {
|
|
339
|
+
req.session.destroy(() => {});
|
|
340
|
+
res.clearCookie(SESSION_COOKIE_NAME, CLEAR_SESSION_COOKIE_OPTIONS);
|
|
341
|
+
return res.status(401).json({ error: 'Session invalid' });
|
|
342
|
+
}
|
|
234
343
|
const { passwordHash, ...safeUser } = user;
|
|
235
344
|
res.json(safeUser);
|
|
236
345
|
});
|
|
@@ -360,8 +469,15 @@ async function seedDefaultAdmin() {
|
|
|
360
469
|
const PORT = Number(process.env.PORT) || 3001;
|
|
361
470
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
362
471
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
472
|
+
mkdir(DATA_DIR, { recursive: true })
|
|
473
|
+
.then(() => seedDefaultAdmin())
|
|
474
|
+
.then(() => {
|
|
475
|
+
console.log(`GreenD data directory: ${DATA_DIR}`);
|
|
476
|
+
app.listen(PORT, HOST, () =>
|
|
477
|
+
console.log(`GreenD server running on http://${HOST}:${PORT}`)
|
|
478
|
+
);
|
|
479
|
+
})
|
|
480
|
+
.catch((err) => {
|
|
481
|
+
console.error('Failed to initialize GreenD server:', err.message);
|
|
482
|
+
process.exit(1);
|
|
483
|
+
});
|