studio-os 0.2.0 → 0.4.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 +159 -86
- package/README.md +270 -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 +163 -141
- package/src/db/003-stripe-invoices.sql +6 -0
- package/src/db/index.js +83 -82
- package/src/routes/me.js +1 -1
- package/src/routes/public.js +13 -3
- package/src/services/auth.js +88 -81
- package/src/services/booking.js +19 -13
- package/src/services/stripe.js +44 -1
- package/test/booking-link.test.js +47 -0
- package/test/deps.test.js +108 -0
- package/test/error-paths.test.js +49 -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/stripe.test.js +84 -1
- package/test/web.test.js +10 -1
- package/views/admin/dashboard.ejs +1 -1
- package/views/admin/settings.ejs +7 -2
- package/views/public/book_result.ejs +5 -5
- 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.4.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": "^18.0.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,163 @@
|
|
|
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
|
-
|
|
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
|
-
app.use((req, res, next) => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
app.use((req, res) =>
|
|
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
|
+
// A pasted Mindbody client export is 100+ bytes a row once URL-encoded, so
|
|
57
|
+
// the 100kb default refused any studio with more than a few hundred clients.
|
|
58
|
+
app.use('/admin/import', express.urlencoded({ extended: true, limit: '20mb' }));
|
|
59
|
+
app.use(express.urlencoded({ extended: true }));
|
|
60
|
+
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
61
|
+
app.use(cookieSession({
|
|
62
|
+
name: 'studio_session',
|
|
63
|
+
keys: [getSetting(db, 'app_secret', 'dev-secret')],
|
|
64
|
+
sameSite: 'lax',
|
|
65
|
+
httpOnly: true,
|
|
66
|
+
maxAge: 30 * 24 * 3600000,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
// View locals: settings + formatting helpers, refreshed per request.
|
|
70
|
+
app.use((req, res, next) => {
|
|
71
|
+
// app_secret signs the session cookie and every magic-link token, so it
|
|
72
|
+
// stays out of the object every template can reach. No view renders it
|
|
73
|
+
// today; this is so none can start to.
|
|
74
|
+
const { app_secret: _secret, ...s } = allSettings(db);
|
|
75
|
+
res.locals.settings = s;
|
|
76
|
+
res.locals.tz = s.timezone || 'Asia/Hong_Kong';
|
|
77
|
+
res.locals.currency = s.currency || 'HKD';
|
|
78
|
+
res.locals.fmtDt = (iso, opts) => formatInTz(res.locals.tz, iso, opts);
|
|
79
|
+
res.locals.fmtMoney = (cents) => new Intl.NumberFormat('en', {
|
|
80
|
+
style: 'currency', currency: res.locals.currency, minimumFractionDigits: 0,
|
|
81
|
+
}).format((cents || 0) / 100);
|
|
82
|
+
res.locals.user = req.session && req.session.userId
|
|
83
|
+
? db.prepare('SELECT id, email, name, role FROM users WHERE id = ?').get(req.session.userId)
|
|
84
|
+
: null;
|
|
85
|
+
// Public views ask "can we take money online?", which needs both keys.
|
|
86
|
+
res.locals.stripeConfigured = services.stripe.checkoutReady;
|
|
87
|
+
res.locals.stripeKeySet = services.stripe.configured;
|
|
88
|
+
res.locals.stripeWebhookSet = services.stripe.webhookConfigured;
|
|
89
|
+
res.locals.smtpConfigured = services.mailer.smtpConfigured;
|
|
90
|
+
res.locals.flash = req.session ? req.session.flash : null;
|
|
91
|
+
if (req.session) delete req.session.flash;
|
|
92
|
+
next();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Rate limiting (before CSRF so hammering can't even probe tokens):
|
|
96
|
+
// in-memory fixed window, per IP + route. Single-process; resets on restart.
|
|
97
|
+
const RL_WINDOW = 15 * 60 * 1000;
|
|
98
|
+
app.post('/magic-link', createRateLimiter({
|
|
99
|
+
windowMs: RL_WINDOW, max: 5, env, now,
|
|
100
|
+
message: 'Too many link requests — please wait 15 minutes and try again.',
|
|
101
|
+
}));
|
|
102
|
+
app.post('/admin/login', createRateLimiter({
|
|
103
|
+
windowMs: RL_WINDOW, max: 10, env, now,
|
|
104
|
+
message: 'Too many login attempts — please wait 15 minutes and try again.',
|
|
105
|
+
}));
|
|
106
|
+
app.post(['/class/:id/book', '/buy/pack/:id', '/buy/membership/:id'], createRateLimiter({
|
|
107
|
+
windowMs: RL_WINDOW, max: 30, env, now,
|
|
108
|
+
message: 'Too many booking attempts from your address — please wait a few minutes and try again.',
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
// CSRF protection: a random per-session token lives in the signed cookie
|
|
112
|
+
// session and must be echoed back (hidden `_csrf` input or `x-csrf-token`
|
|
113
|
+
// header) on every state-changing request. /webhooks/stripe is exempt — it
|
|
114
|
+
// is mounted before this middleware and authenticated by Stripe signature.
|
|
115
|
+
app.use((req, res, next) => {
|
|
116
|
+
if (!req.session.csrf) req.session.csrf = crypto.randomBytes(24).toString('hex');
|
|
117
|
+
res.locals.csrfToken = req.session.csrf;
|
|
118
|
+
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return next();
|
|
119
|
+
if (req.path.startsWith('/webhooks/')) return next();
|
|
120
|
+
const sent = (req.body && req.body._csrf) || req.get('x-csrf-token') || '';
|
|
121
|
+
const a = Buffer.from(String(sent));
|
|
122
|
+
const b = Buffer.from(req.session.csrf);
|
|
123
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
124
|
+
return res.status(403).render('error', {
|
|
125
|
+
title: 'Session expired',
|
|
126
|
+
message: 'Your form session expired or the security token was missing. Please go back, refresh the page, and try again.',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
next();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// First-run gate: until setup completes, everything routes to the wizard.
|
|
133
|
+
app.use((req, res, next) => {
|
|
134
|
+
const done = getSetting(db, 'setup_complete', '0') === '1';
|
|
135
|
+
if (!done && !req.path.startsWith('/setup') && !req.path.startsWith('/vendor')
|
|
136
|
+
&& !req.path.startsWith('/css') && !req.path.startsWith('/js')) {
|
|
137
|
+
return res.redirect('/setup');
|
|
138
|
+
}
|
|
139
|
+
if (done && req.path.startsWith('/setup')) return res.redirect('/');
|
|
140
|
+
next();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
app.use('/setup', setupRoutes(services));
|
|
144
|
+
app.use('/', authRoutes(services));
|
|
145
|
+
app.use('/admin', adminRoutes(services));
|
|
146
|
+
app.use('/instructor', instructorRoutes(services));
|
|
147
|
+
app.use('/me', meRoutes(services));
|
|
148
|
+
app.use('/', publicRoutes(services));
|
|
149
|
+
|
|
150
|
+
// 404 + error handler
|
|
151
|
+
app.use((req, res) => res.status(404).render('error', { title: 'Not found', message: 'Page not found.' }));
|
|
152
|
+
// eslint-disable-next-line no-unused-vars
|
|
153
|
+
app.use((err, req, res, next) => {
|
|
154
|
+
console.error('[error]', err);
|
|
155
|
+
// Body-parser errors (oversized or malformed bodies) arrive before the
|
|
156
|
+
// locals middleware has run, and the layout can't render without these.
|
|
157
|
+
res.locals = { settings: {}, user: null, flash: null, ...res.locals };
|
|
158
|
+
const status = err.status >= 400 && err.status < 500 ? err.status : 500;
|
|
159
|
+
res.status(status).render('error', { title: 'Error', message: err.expose ? err.message : 'Something went wrong.' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return app;
|
|
163
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- v3: membership renewals are recorded from Stripe invoices, keyed on the
|
|
2
|
+
-- invoice id so webhook retries can't record the same month twice.
|
|
3
|
+
|
|
4
|
+
ALTER TABLE payments ADD COLUMN stripe_invoice_id TEXT;
|
|
5
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_payments_invoice
|
|
6
|
+
ON payments(stripe_invoice_id) WHERE stripe_invoice_id IS NOT NULL;
|
package/src/db/index.js
CHANGED
|
@@ -1,82 +1,83 @@
|
|
|
1
|
-
import Database from 'better-sqlite3';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import crypto from 'node:crypto';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
|
|
7
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
|
|
9
|
-
const MIGRATIONS = [
|
|
10
|
-
{ version: 1, file: 'schema.sql' },
|
|
11
|
-
{ version: 2, file: '002-instructor-role.sql' },
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
db
|
|
34
|
-
db.pragma('
|
|
35
|
-
db.pragma('
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
db.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
const MIGRATIONS = [
|
|
10
|
+
{ version: 1, file: 'schema.sql' },
|
|
11
|
+
{ version: 2, file: '002-instructor-role.sql' },
|
|
12
|
+
{ version: 3, file: '003-stripe-invoices.sql' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const DEFAULT_SETTINGS = {
|
|
16
|
+
studio_name: '',
|
|
17
|
+
timezone: 'Asia/Hong_Kong',
|
|
18
|
+
currency: 'HKD',
|
|
19
|
+
cancellation_window_hours: '12',
|
|
20
|
+
late_cancel_policy: 'forfeit', // forfeit | refund
|
|
21
|
+
waiver_markdown: '## Liability Waiver\n\nI understand that participation in classes involves physical activity and I take part at my own risk. I confirm I am in good health and will inform the instructor of any injuries or conditions.\n\nBy checking the box I agree to this waiver.',
|
|
22
|
+
setup_complete: '0',
|
|
23
|
+
horizon_weeks: '8',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Open (or create) the database, enable WAL, run migrations, seed defaults.
|
|
28
|
+
* @param {string} [dbPath] file path or ':memory:' (tests)
|
|
29
|
+
*/
|
|
30
|
+
export function openDb(dbPath) {
|
|
31
|
+
const file = dbPath || process.env.DB_PATH || path.join(process.cwd(), 'data', 'studio.db');
|
|
32
|
+
if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
33
|
+
const db = new Database(file);
|
|
34
|
+
db.pragma('journal_mode = WAL');
|
|
35
|
+
db.pragma('foreign_keys = ON');
|
|
36
|
+
db.pragma('busy_timeout = 5000');
|
|
37
|
+
migrate(db);
|
|
38
|
+
return db;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function migrate(db) {
|
|
42
|
+
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
|
|
43
|
+
const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
|
|
44
|
+
for (const m of MIGRATIONS) {
|
|
45
|
+
if (applied.has(m.version)) continue;
|
|
46
|
+
const sql = fs.readFileSync(path.join(__dirname, m.file), 'utf8');
|
|
47
|
+
db.transaction(() => {
|
|
48
|
+
db.exec(sql);
|
|
49
|
+
db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
|
|
50
|
+
.run(m.version, new Date().toISOString());
|
|
51
|
+
})();
|
|
52
|
+
}
|
|
53
|
+
// seed default settings (INSERT OR IGNORE keeps existing values)
|
|
54
|
+
const ins = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
|
55
|
+
for (const [k, v] of Object.entries(DEFAULT_SETTINGS)) ins.run(k, v);
|
|
56
|
+
// app secret: generated once, persisted, overridable via env
|
|
57
|
+
if (!process.env.APP_SECRET) {
|
|
58
|
+
ins.run('app_secret', crypto.randomBytes(32).toString('hex'));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getSetting(db, key, fallback = '') {
|
|
63
|
+
if (key === 'app_secret' && process.env.APP_SECRET) return process.env.APP_SECRET;
|
|
64
|
+
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
|
65
|
+
return row ? row.value : fallback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function setSetting(db, key, value) {
|
|
69
|
+
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
70
|
+
.run(key, String(value));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function allSettings(db) {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const r of db.prepare('SELECT key, value FROM settings').all()) out[r.key] = r.value;
|
|
76
|
+
if (process.env.APP_SECRET) out.app_secret = process.env.APP_SECRET;
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function audit(db, who, action, entity = '') {
|
|
81
|
+
db.prepare('INSERT INTO audit_log (who, action, entity, at) VALUES (?, ?, ?, ?)')
|
|
82
|
+
.run(who || '', action, entity, new Date().toISOString());
|
|
83
|
+
}
|
package/src/routes/me.js
CHANGED
|
@@ -10,7 +10,7 @@ export default function meRoutes(services) {
|
|
|
10
10
|
const r = Router();
|
|
11
11
|
|
|
12
12
|
function requireClient(req, res) {
|
|
13
|
-
const token = req.query.token || req.body.token;
|
|
13
|
+
const token = req.query.token || (req.body && req.body.token);
|
|
14
14
|
const clientId = verifyMagicToken(db, token);
|
|
15
15
|
if (!clientId) {
|
|
16
16
|
res.status(401).render('public/magic_request', {
|