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.
Files changed (3) hide show
  1. package/.env.example +10 -4
  2. package/package.json +1 -1
  3. 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, 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
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "greend-server",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "GreenD ESG backend — self-hosted Express.js API server",
5
5
  "type": "module",
6
6
  "main": "server.js",
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
- : join(__dirname, 'data');
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
- res.json(safeUser);
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
- req.session.destroy(() => res.sendStatus(204));
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) return res.status(401).json({ error: 'Session invalid' });
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
- seedDefaultAdmin().then(() => {
364
- app.listen(PORT, HOST, () =>
365
- console.log(`GreenD server running on http://${HOST}:${PORT}`)
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
+ });