studio-os 0.2.0 → 0.3.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 +7 -3
- package/.gitattributes +8 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +49 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +30 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +20 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/guards.yml +45 -0
- package/CHANGELOG.md +124 -86
- package/README.md +263 -253
- package/SECURITY.md +27 -0
- package/SPEC.md +4 -4
- package/package.json +54 -54
- package/runner-lock.json +15 -0
- package/src/app.js +156 -141
- package/src/routes/public.js +2 -2
- package/src/services/auth.js +88 -81
- package/src/services/booking.js +19 -13
- package/src/services/stripe.js +6 -0
- package/test/deps.test.js +108 -0
- package/test/membership-cycle.test.js +78 -0
- package/test/proxy-auth.test.js +114 -0
- package/test/stripe-halfconfig.test.js +98 -0
- package/test/web.test.js +5 -1
- package/views/admin/dashboard.ejs +1 -1
- package/views/admin/settings.ejs +7 -2
- package/PROGRESS.md +0 -60
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "studio-os",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Self-hosted studio management: booking, class packs, memberships. Bring your own Stripe.",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"main": "src/server.js",
|
|
8
|
-
"scripts": {
|
|
9
|
-
"start": "node src/server.js",
|
|
10
|
-
"dev": "node --watch src/server.js",
|
|
11
|
-
"test": "node --test \"test/*.test.js\"",
|
|
12
|
-
"seed": "node scripts/seed.mjs",
|
|
13
|
-
"import-mindbody": "node scripts/import-mindbody.mjs"
|
|
14
|
-
},
|
|
15
|
-
"engines": {
|
|
16
|
-
"node": ">=
|
|
17
|
-
},
|
|
18
|
-
"dependencies": {
|
|
19
|
-
"bcryptjs": "^
|
|
20
|
-
"better-sqlite3": "^
|
|
21
|
-
"cookie-session": "^2.1.0",
|
|
22
|
-
"dotenv": "^
|
|
23
|
-
"ejs": "^
|
|
24
|
-
"express": "^
|
|
25
|
-
"marked": "^
|
|
26
|
-
"nodemailer": "^
|
|
27
|
-
"stripe": "^
|
|
28
|
-
},
|
|
29
|
-
"devDependencies": {
|
|
30
|
-
"supertest": "^7.0.0"
|
|
31
|
-
},
|
|
32
|
-
"repository": {
|
|
33
|
-
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/Booyaka101/studio-os.git"
|
|
35
|
-
},
|
|
36
|
-
"homepage": "https://github.com/Booyaka101/studio-os#readme",
|
|
37
|
-
"bugs": {
|
|
38
|
-
"url": "https://github.com/Booyaka101/studio-os/issues"
|
|
39
|
-
},
|
|
40
|
-
"keywords": [
|
|
41
|
-
"self-hosted",
|
|
42
|
-
"studio",
|
|
43
|
-
"yoga",
|
|
44
|
-
"pilates",
|
|
45
|
-
"gym",
|
|
46
|
-
"booking",
|
|
47
|
-
"class-packs",
|
|
48
|
-
"memberships",
|
|
49
|
-
"mindbody-alternative",
|
|
50
|
-
"stripe",
|
|
51
|
-
"scheduling",
|
|
52
|
-
"fitness"
|
|
53
|
-
]
|
|
54
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "studio-os",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Self-hosted studio management: booking, class packs, memberships. Bring your own Stripe.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/server.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node src/server.js",
|
|
10
|
+
"dev": "node --watch src/server.js",
|
|
11
|
+
"test": "node --test \"test/*.test.js\"",
|
|
12
|
+
"seed": "node scripts/seed.mjs",
|
|
13
|
+
"import-mindbody": "node scripts/import-mindbody.mjs"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"bcryptjs": "^3.0.3",
|
|
20
|
+
"better-sqlite3": "^13.0.1",
|
|
21
|
+
"cookie-session": "^2.1.0",
|
|
22
|
+
"dotenv": "^17.4.2",
|
|
23
|
+
"ejs": "^6.0.1",
|
|
24
|
+
"express": "^5.2.1",
|
|
25
|
+
"marked": "^18.0.9",
|
|
26
|
+
"nodemailer": "^10.0.9",
|
|
27
|
+
"stripe": "^22.6.2"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"supertest": "^7.0.0"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/Booyaka101/studio-os.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/Booyaka101/studio-os#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/Booyaka101/studio-os/issues"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"self-hosted",
|
|
42
|
+
"studio",
|
|
43
|
+
"yoga",
|
|
44
|
+
"pilates",
|
|
45
|
+
"gym",
|
|
46
|
+
"booking",
|
|
47
|
+
"class-packs",
|
|
48
|
+
"memberships",
|
|
49
|
+
"mindbody-alternative",
|
|
50
|
+
"stripe",
|
|
51
|
+
"scheduling",
|
|
52
|
+
"fitness"
|
|
53
|
+
]
|
|
54
|
+
}
|
package/runner-lock.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"label": "ubuntu-24.04",
|
|
4
|
+
"imageOS": "ubuntu24",
|
|
5
|
+
"imageVersion": "20260720.247.2",
|
|
6
|
+
"tools": {
|
|
7
|
+
"Node.js": {
|
|
8
|
+
"versions": [
|
|
9
|
+
"22.23.1"
|
|
10
|
+
],
|
|
11
|
+
"source": "manifest"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"updatedAt": "2026-08-05T04:27:45.130Z"
|
|
15
|
+
}
|
package/src/app.js
CHANGED
|
@@ -1,141 +1,156 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import cookieSession from 'cookie-session';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { getSetting, allSettings } from './db/index.js';
|
|
7
|
-
import { formatInTz } from './lib/time.js';
|
|
8
|
-
import { createRateLimiter } from './lib/ratelimit.js';
|
|
9
|
-
import { createMailer } from './services/mailer.js';
|
|
10
|
-
import { createStripeService } from './services/stripe.js';
|
|
11
|
-
import setupRoutes from './routes/setup.js';
|
|
12
|
-
import authRoutes from './routes/auth.js';
|
|
13
|
-
import publicRoutes from './routes/public.js';
|
|
14
|
-
import meRoutes from './routes/me.js';
|
|
15
|
-
import adminRoutes from './routes/admin.js';
|
|
16
|
-
import instructorRoutes from './routes/instructor.js';
|
|
17
|
-
import webhookRoutes from './routes/webhooks.js';
|
|
18
|
-
|
|
19
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Build the Express app. Dependencies are injectable for tests:
|
|
23
|
-
* createApp({ db, mailer?, stripeService?, env?, now? })
|
|
24
|
-
* `now` (clock) is used by the rate limiters so tests can move time.
|
|
25
|
-
*/
|
|
26
|
-
export function createApp({ db, mailer, stripeService, env = process.env, now = Date.now }) {
|
|
27
|
-
const app = express();
|
|
28
|
-
app.set('view engine', 'ejs');
|
|
29
|
-
app.set('views', path.join(__dirname, '..', 'views'));
|
|
30
|
-
app.disable('x-powered-by');
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
app.use((
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
res.locals.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
app.use(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cookieSession from 'cookie-session';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { getSetting, allSettings } from './db/index.js';
|
|
7
|
+
import { formatInTz } from './lib/time.js';
|
|
8
|
+
import { createRateLimiter } from './lib/ratelimit.js';
|
|
9
|
+
import { createMailer } from './services/mailer.js';
|
|
10
|
+
import { createStripeService } from './services/stripe.js';
|
|
11
|
+
import setupRoutes from './routes/setup.js';
|
|
12
|
+
import authRoutes from './routes/auth.js';
|
|
13
|
+
import publicRoutes from './routes/public.js';
|
|
14
|
+
import meRoutes from './routes/me.js';
|
|
15
|
+
import adminRoutes from './routes/admin.js';
|
|
16
|
+
import instructorRoutes from './routes/instructor.js';
|
|
17
|
+
import webhookRoutes from './routes/webhooks.js';
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the Express app. Dependencies are injectable for tests:
|
|
23
|
+
* createApp({ db, mailer?, stripeService?, env?, now? })
|
|
24
|
+
* `now` (clock) is used by the rate limiters so tests can move time.
|
|
25
|
+
*/
|
|
26
|
+
export function createApp({ db, mailer, stripeService, env = process.env, now = Date.now }) {
|
|
27
|
+
const app = express();
|
|
28
|
+
app.set('view engine', 'ejs');
|
|
29
|
+
app.set('views', path.join(__dirname, '..', 'views'));
|
|
30
|
+
app.disable('x-powered-by');
|
|
31
|
+
|
|
32
|
+
// Behind a TLS-terminating proxy the socket is plain HTTP, so req.protocol
|
|
33
|
+
// reads 'http' and every emailed magic link ships a 7-day auth token over
|
|
34
|
+
// plaintext. Honouring X-Forwarded-Proto is what makes baseUrl() correct.
|
|
35
|
+
// TRUST_PROXY is a hop count ('1') or any Express trust-proxy value.
|
|
36
|
+
if (env.TRUST_PROXY) {
|
|
37
|
+
const n = Number(env.TRUST_PROXY);
|
|
38
|
+
app.set('trust proxy', Number.isInteger(n) && n > 0 ? n : env.TRUST_PROXY);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const services = {
|
|
42
|
+
db,
|
|
43
|
+
mailer: mailer || createMailer({ env }),
|
|
44
|
+
stripe: stripeService || createStripeService({ env }),
|
|
45
|
+
env,
|
|
46
|
+
baseUrl(req) {
|
|
47
|
+
return env.BASE_URL || `${req.protocol}://${req.get('host')}`;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
app.locals.services = services;
|
|
51
|
+
|
|
52
|
+
// Stripe webhook needs the raw body for signature verification — mount
|
|
53
|
+
// before any body parser.
|
|
54
|
+
app.use('/webhooks/stripe', express.raw({ type: '*/*' }), webhookRoutes(services));
|
|
55
|
+
|
|
56
|
+
app.use(express.urlencoded({ extended: true }));
|
|
57
|
+
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
58
|
+
app.use(cookieSession({
|
|
59
|
+
name: 'studio_session',
|
|
60
|
+
keys: [getSetting(db, 'app_secret', 'dev-secret')],
|
|
61
|
+
sameSite: 'lax',
|
|
62
|
+
httpOnly: true,
|
|
63
|
+
maxAge: 30 * 24 * 3600000,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
// View locals: settings + formatting helpers, refreshed per request.
|
|
67
|
+
app.use((req, res, next) => {
|
|
68
|
+
// app_secret signs the session cookie and every magic-link token, so it
|
|
69
|
+
// stays out of the object every template can reach. No view renders it
|
|
70
|
+
// today; this is so none can start to.
|
|
71
|
+
const { app_secret: _secret, ...s } = allSettings(db);
|
|
72
|
+
res.locals.settings = s;
|
|
73
|
+
res.locals.tz = s.timezone || 'Asia/Hong_Kong';
|
|
74
|
+
res.locals.currency = s.currency || 'HKD';
|
|
75
|
+
res.locals.fmtDt = (iso, opts) => formatInTz(res.locals.tz, iso, opts);
|
|
76
|
+
res.locals.fmtMoney = (cents) => new Intl.NumberFormat('en', {
|
|
77
|
+
style: 'currency', currency: res.locals.currency, minimumFractionDigits: 0,
|
|
78
|
+
}).format((cents || 0) / 100);
|
|
79
|
+
res.locals.user = req.session && req.session.userId
|
|
80
|
+
? db.prepare('SELECT id, email, name, role FROM users WHERE id = ?').get(req.session.userId)
|
|
81
|
+
: null;
|
|
82
|
+
// Public views ask "can we take money online?", which needs both keys.
|
|
83
|
+
res.locals.stripeConfigured = services.stripe.checkoutReady;
|
|
84
|
+
res.locals.stripeKeySet = services.stripe.configured;
|
|
85
|
+
res.locals.stripeWebhookSet = services.stripe.webhookConfigured;
|
|
86
|
+
res.locals.smtpConfigured = services.mailer.smtpConfigured;
|
|
87
|
+
res.locals.flash = req.session ? req.session.flash : null;
|
|
88
|
+
if (req.session) delete req.session.flash;
|
|
89
|
+
next();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Rate limiting (before CSRF so hammering can't even probe tokens):
|
|
93
|
+
// in-memory fixed window, per IP + route. Single-process; resets on restart.
|
|
94
|
+
const RL_WINDOW = 15 * 60 * 1000;
|
|
95
|
+
app.post('/magic-link', createRateLimiter({
|
|
96
|
+
windowMs: RL_WINDOW, max: 5, env, now,
|
|
97
|
+
message: 'Too many link requests — please wait 15 minutes and try again.',
|
|
98
|
+
}));
|
|
99
|
+
app.post('/admin/login', createRateLimiter({
|
|
100
|
+
windowMs: RL_WINDOW, max: 10, env, now,
|
|
101
|
+
message: 'Too many login attempts — please wait 15 minutes and try again.',
|
|
102
|
+
}));
|
|
103
|
+
app.post(['/class/:id/book', '/buy/pack/:id', '/buy/membership/:id'], createRateLimiter({
|
|
104
|
+
windowMs: RL_WINDOW, max: 30, env, now,
|
|
105
|
+
message: 'Too many booking attempts from your address — please wait a few minutes and try again.',
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
// CSRF protection: a random per-session token lives in the signed cookie
|
|
109
|
+
// session and must be echoed back (hidden `_csrf` input or `x-csrf-token`
|
|
110
|
+
// header) on every state-changing request. /webhooks/stripe is exempt — it
|
|
111
|
+
// is mounted before this middleware and authenticated by Stripe signature.
|
|
112
|
+
app.use((req, res, next) => {
|
|
113
|
+
if (!req.session.csrf) req.session.csrf = crypto.randomBytes(24).toString('hex');
|
|
114
|
+
res.locals.csrfToken = req.session.csrf;
|
|
115
|
+
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return next();
|
|
116
|
+
if (req.path.startsWith('/webhooks/')) return next();
|
|
117
|
+
const sent = (req.body && req.body._csrf) || req.get('x-csrf-token') || '';
|
|
118
|
+
const a = Buffer.from(String(sent));
|
|
119
|
+
const b = Buffer.from(req.session.csrf);
|
|
120
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
121
|
+
return res.status(403).render('error', {
|
|
122
|
+
title: 'Session expired',
|
|
123
|
+
message: 'Your form session expired or the security token was missing. Please go back, refresh the page, and try again.',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
next();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// First-run gate: until setup completes, everything routes to the wizard.
|
|
130
|
+
app.use((req, res, next) => {
|
|
131
|
+
const done = getSetting(db, 'setup_complete', '0') === '1';
|
|
132
|
+
if (!done && !req.path.startsWith('/setup') && !req.path.startsWith('/vendor')
|
|
133
|
+
&& !req.path.startsWith('/css') && !req.path.startsWith('/js')) {
|
|
134
|
+
return res.redirect('/setup');
|
|
135
|
+
}
|
|
136
|
+
if (done && req.path.startsWith('/setup')) return res.redirect('/');
|
|
137
|
+
next();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
app.use('/setup', setupRoutes(services));
|
|
141
|
+
app.use('/', authRoutes(services));
|
|
142
|
+
app.use('/admin', adminRoutes(services));
|
|
143
|
+
app.use('/instructor', instructorRoutes(services));
|
|
144
|
+
app.use('/me', meRoutes(services));
|
|
145
|
+
app.use('/', publicRoutes(services));
|
|
146
|
+
|
|
147
|
+
// 404 + error handler
|
|
148
|
+
app.use((req, res) => res.status(404).render('error', { title: 'Not found', message: 'Page not found.' }));
|
|
149
|
+
// eslint-disable-next-line no-unused-vars
|
|
150
|
+
app.use((err, req, res, next) => {
|
|
151
|
+
console.error('[error]', err);
|
|
152
|
+
res.status(500).render('error', { title: 'Error', message: err.expose ? err.message : 'Something went wrong.' });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return app;
|
|
156
|
+
}
|
package/src/routes/public.js
CHANGED
|
@@ -150,7 +150,7 @@ export default function publicRoutes(services) {
|
|
|
150
150
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
151
151
|
return res.status(400).render('public/buy_manual', { title: 'Buy', product, plan: null, email: null, error: 'A valid email is required.' });
|
|
152
152
|
}
|
|
153
|
-
if (services.stripe.
|
|
153
|
+
if (services.stripe.checkoutReady) {
|
|
154
154
|
try {
|
|
155
155
|
const session = await services.stripe.createPackCheckout({
|
|
156
156
|
product, clientEmail: email, baseUrl: services.baseUrl(req),
|
|
@@ -171,7 +171,7 @@ export default function publicRoutes(services) {
|
|
|
171
171
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
172
172
|
return res.status(400).render('public/buy_manual', { title: 'Buy', product: null, plan, email: null, error: 'A valid email is required.' });
|
|
173
173
|
}
|
|
174
|
-
if (services.stripe.
|
|
174
|
+
if (services.stripe.checkoutReady && plan.stripe_price_id) {
|
|
175
175
|
try {
|
|
176
176
|
const session = await services.stripe.createMembershipCheckout({
|
|
177
177
|
plan, clientEmail: email, baseUrl: services.baseUrl(req),
|
package/src/services/auth.js
CHANGED
|
@@ -1,81 +1,88 @@
|
|
|
1
|
-
// Staff auth (bcryptjs) + HMAC magic-link tokens for client self-service.
|
|
2
|
-
import bcrypt from 'bcryptjs';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import { getSetting } from '../db/index.js';
|
|
5
|
-
|
|
6
|
-
export function hashPassword(plain) {
|
|
7
|
-
return bcrypt.hashSync(plain, 10);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function verifyPassword(plain, hash) {
|
|
11
|
-
return bcrypt.compareSync(plain, hash);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function createUser(db, { email, password, name = '', role = 'staff' }) {
|
|
15
|
-
const info = db.prepare(
|
|
16
|
-
'INSERT INTO users (email, password_hash, name, role) VALUES (?, ?, ?, ?)'
|
|
17
|
-
).run(email.trim().toLowerCase(), hashPassword(password), name, role);
|
|
18
|
-
return info.lastInsertRowid;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
next();
|
|
81
|
-
}
|
|
1
|
+
// Staff auth (bcryptjs) + HMAC magic-link tokens for client self-service.
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { getSetting } from '../db/index.js';
|
|
5
|
+
|
|
6
|
+
export function hashPassword(plain) {
|
|
7
|
+
return bcrypt.hashSync(plain, 10);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function verifyPassword(plain, hash) {
|
|
11
|
+
return bcrypt.compareSync(plain, hash);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createUser(db, { email, password, name = '', role = 'staff' }) {
|
|
15
|
+
const info = db.prepare(
|
|
16
|
+
'INSERT INTO users (email, password_hash, name, role) VALUES (?, ?, ?, ?)'
|
|
17
|
+
).run(email.trim().toLowerCase(), hashPassword(password), name, role);
|
|
18
|
+
return info.lastInsertRowid;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Returning early for an unknown email skips bcrypt entirely, which answers in
|
|
22
|
+
// microseconds instead of tens of milliseconds and turns the login form into a
|
|
23
|
+
// staff-email oracle. Comparing against a throwaway hash of the same cost keeps
|
|
24
|
+
// both paths the same shape. /magic-link already refuses to leak which emails
|
|
25
|
+
// exist; this is the same promise on the staff side.
|
|
26
|
+
const DUMMY_HASH = bcrypt.hashSync('password-that-is-never-valid', 10);
|
|
27
|
+
|
|
28
|
+
export function authenticate(db, email, password) {
|
|
29
|
+
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(String(email || '').trim().toLowerCase());
|
|
30
|
+
const ok = verifyPassword(String(password || ''), user ? user.password_hash : DUMMY_HASH);
|
|
31
|
+
return user && ok ? user : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- magic links -----------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function hmac(secret, payload) {
|
|
37
|
+
return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Signed token for a client, valid for ttlHours (default 7 days). */
|
|
41
|
+
export function makeMagicToken(db, clientId, { ttlHours = 24 * 7, now = Date.now() } = {}) {
|
|
42
|
+
const secret = getSetting(db, 'app_secret');
|
|
43
|
+
const expires = now + ttlHours * 3600000;
|
|
44
|
+
const payload = `${clientId}.${expires}`;
|
|
45
|
+
return `${payload}.${hmac(secret, payload)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Returns clientId or null. Constant-time signature check. */
|
|
49
|
+
export function verifyMagicToken(db, token, { now = Date.now() } = {}) {
|
|
50
|
+
if (typeof token !== 'string') return null;
|
|
51
|
+
const parts = token.split('.');
|
|
52
|
+
if (parts.length !== 3) return null;
|
|
53
|
+
const [idStr, expStr, sig] = parts;
|
|
54
|
+
const secret = getSetting(db, 'app_secret');
|
|
55
|
+
const expected = hmac(secret, `${idStr}.${expStr}`);
|
|
56
|
+
const a = Buffer.from(sig);
|
|
57
|
+
const b = Buffer.from(expected);
|
|
58
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
|
59
|
+
if (Number(expStr) < now) return null;
|
|
60
|
+
const id = Number(idStr);
|
|
61
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- express middleware ----------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export function requireStaff(req, res, next) {
|
|
67
|
+
if (req.session && req.session.userId) return next();
|
|
68
|
+
return res.redirect('/admin/login');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Admin area: any logged-in owner/staff. Instructors get a hard 403. */
|
|
72
|
+
export function requireAdmin(req, res, next) {
|
|
73
|
+
if (!req.session || !req.session.userId || !res.locals.user) return res.redirect('/admin/login');
|
|
74
|
+
if (res.locals.user.role === 'instructor') {
|
|
75
|
+
return res.status(403).render('error', {
|
|
76
|
+
title: 'Forbidden',
|
|
77
|
+
message: 'Instructor accounts cannot access the admin area.',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
next();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Instructor portal: instructor role only; admins are sent to their own UI. */
|
|
84
|
+
export function requireInstructor(req, res, next) {
|
|
85
|
+
if (!req.session || !req.session.userId || !res.locals.user) return res.redirect('/admin/login');
|
|
86
|
+
if (res.locals.user.role !== 'instructor') return res.redirect('/admin');
|
|
87
|
+
next();
|
|
88
|
+
}
|