-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
259 lines (224 loc) · 9.5 KB
/
Copy pathindex.js
File metadata and controls
259 lines (224 loc) · 9.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
45
46
47
48
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
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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
import { fileURLToPath } from 'url';
import path from 'path';
import process from 'process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
// Sit behind a reverse proxy (Vercel / nginx) in production; trust it so the
// rate limiter keys off the real client IP rather than the proxy's.
app.set('trust proxy', 1);
// Baseline security headers (nosniff, frameguard, HSTS, etc.). The CSP is
// loosened just enough for the docs site: inline styles (syntax highlighting)
// and `connect-src *` so the playground can target another API host.
app.use(helmet({
contentSecurityPolicy: {
directives: {
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:', 'https:'],
'connect-src': ["'self'", '*'],
},
},
}));
// Cap request bodies. Login/session envelopes are small; without a limit a
// client could POST an arbitrarily large body and exhaust memory.
app.use(express.json({ limit: '256kb' }));
app.use((err, req, res, next) => {
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
return res.status(400).json({
success: false,
message: 'Invalid JSON: ' + err.message
});
}
next(err);
});
import * as webPushService from './web-push.js';
import supabase from './database.js';
import { createPlatformRoutes } from './core/index.js';
import hac from './hac/index.js';
import skywardLegacy from './skyward-legacy/index.js';
import powerschool from './powerschool/index.js';
import demo from './demo/index.js';
// Every platform is a registry object; core turns it into routes and mounts it
// at its declared prefix. Add a platform by importing it and pushing it here.
const platforms = [hac, skywardLegacy, powerschool];
// All origins are allowed (Vercel preview deployments use unpredictable hosts).
// The API uses no cookies/credentials from the browser, so this is safe.
app.use(cors());
// Global rate limit — a coarse ceiling against scraping/abuse. The data routes
// make outbound requests to school portals on the caller's behalf, so an
// unthrottled client could use the API to hammer those portals.
const globalLimiter = rateLimit({
windowMs: 60 * 1000,
limit: Number(process.env.RATE_LIMIT_PER_MIN) || 120,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { success: false, message: 'Too many requests, please slow down.' },
});
app.use(globalLimiter);
// Tighter limit for unauthenticated write/enumeration endpoints (subscription
// spam, username enumeration).
const strictLimiter = rateLimit({
windowMs: 60 * 1000,
limit: Number(process.env.STRICT_RATE_LIMIT_PER_MIN) || 20,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { success: false, message: 'Too many requests, please slow down.' },
});
for (const platform of platforms) {
app.use(platform.mount, createPlatformRoutes(platform));
}
app.use('/demo', demo);
app.use('/static', express.static(__dirname + '/static'));
// API docs: a prebuilt SPA (docs/ → `npm run docs:build`). Client-side routes
// are GET-only and never collide with the POST platform routes.
const docsDist = path.join(__dirname, 'docs', 'dist');
app.use(express.static(docsDist, { index: false }));
app.get(['/', '/guides/*', '/api/*', '/platforms', '/platforms/*'], (req, res) => {
res.sendFile(path.join(docsDist, 'index.html'), (err) => {
if (err) res.status(404).send('Docs not built. Run `npm run docs:build`.');
});
});
// Kept at its original path so existing web/mobile builds don't 404, but the
// referral programme is gone: this now only answers "is this user blocked?".
// That check reads BLOCKED_USERS, never the database, so it no longer 500s for a
// username that has never signed in.
app.get('/referral', strictLimiter, async (req, res) => {
try {
let { username } = req.query;
// `?username=a&username=b` parses to an array; reject anything non-string.
if (!username || typeof username !== 'string') {
return res.status(400).json({ error: 'username is required' });
}
username = username.toLowerCase();
const blockedEnv = process.env.BLOCKED_USERS || '';
const blockedList = blockedEnv.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const blocked = blockedList.includes(username);
res.json({ blocked });
} catch (error) {
console.error('Blocked-user lookup failed:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
app.get('/vapid-public-key', (req, res) => {
const { platform } = req.query;
const publicKey = webPushService.getVapidPublicKey(platform);
res.json({ publicKey });
});
// Public read of the announcements table for the web app. These are broadcast
// notices, not per-user data, so no auth is required.
app.get('/web-notifications', async (req, res) => {
try {
const { data, error } = await supabase
.from('notifications')
.select('*')
.order('created_at', { ascending: false });
if (error) {
throw error;
}
res.json({ data });
} catch (error) {
console.error('web-notifications fetch failed:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
// Public aggregate for the splash page: total users and users per school, never
// individual rows. Cached in memory and at the edge so a busy splash page costs
// at most one tiny database read per STATS_TTL_MS per instance.
const STATS_TTL_MS = 30 * 1000;
let statsCache = { at: 0, body: null, pending: null };
async function loadStats() {
const { data, error } = await supabase.from('school_counts').select('school,count');
if (error) throw error;
const schools = data
.filter((row) => row.count > 0)
.sort((a, b) => b.count - a.count)
.map((row) => [row.school, row.count]);
const total = schools.reduce((sum, [, count]) => sum + count, 0);
return { total, schools };
}
app.get('/stats', async (req, res) => {
try {
if (!statsCache.body || Date.now() - statsCache.at > STATS_TTL_MS) {
// Collapse concurrent refreshes into one query.
statsCache.pending ??= loadStats().finally(() => { statsCache.pending = null; });
statsCache.body = await statsCache.pending;
statsCache.at = Date.now();
}
res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
// Express adds an ETag, so an unchanged poll gets a bodiless 304.
res.json(statsCache.body);
} catch (error) {
console.error('stats fetch failed:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
app.post('/subscribe', strictLimiter, async (req, res) => {
try {
const { payload, platform = 'web' } = req.body;
if (!payload) {
return res.status(400).json({ message: 'payload is required' });
}
await webPushService.addSubscription(payload, platform);
console.log('New device subscribed:', platform);
res.status(201).json({ message: 'Subscription received successfully.' });
} catch (error) {
console.error('Failed to save subscription:', error);
res.status(500).json({ message: 'Failed to save subscription' });
}
});
async function sendPushToAllDevices() {
return webPushService.sendPushToAllDevices();
}
// Deployment schedulers can call this endpoint because in-process timers do not
// survive scale-to-zero/serverless restarts. Keep it protected: broadcasting a
// trigger makes every subscribed client perform a portal fetch.
app.post('/push/trigger', strictLimiter, async (req, res) => {
const configuredSecret = process.env.PUSH_TRIGGER_SECRET;
const suppliedSecret = req.get('authorization')?.replace(/^Bearer\s+/i, '');
if (!configuredSecret || suppliedSecret !== configuredSecret) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
try {
const delivery = await sendPushToAllDevices();
// Report what actually landed. A broadcast where every ticket was rejected
// is not a success, however cleanly the request itself completed — reporting
// it as one is what hid a total Android push outage behind a 200.
const attempted = delivery.expo.attempted + delivery.web.attempted;
const sent = delivery.expo.sent + delivery.web.sent;
res.json({ success: attempted === 0 || sent > 0, delivery });
} catch (error) {
console.error('Scheduled push trigger failed:', error);
res.status(500).json({ success: false, message: 'Push trigger failed' });
}
});
// How often to fire the "go fetch" trigger, in minutes. Falls back to 1 hour.
const pushIntervalMinutes = Number(process.env.PUSH_INTERVAL_MINUTES) || 60;
setInterval(() => {
sendPushToAllDevices()
.catch(() => console.error('Failed to send push notifications.'));
}, 1000 * 60 * pushIntervalMinutes);
console.log(`Push trigger scheduled every ${pushIntervalMinutes} minute(s)`);
app.use((err, req, res, next) => {
console.error('Global error handler:', err);
const status = err.status || err.statusCode || 500;
// Only surface a message for deliberate 4xx client errors (validation, auth).
// For 5xx, hide the internal error text so stack/DB details aren't leaked.
const message = status < 500 ? err.message || 'Bad Request' : 'Internal Server Error';
if (!res.headersSent) {
res.status(status).json({
success: false,
message,
});
}
});
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => {
console.log(`Main App listening on http://localhost:${port}`);
});
export default app;