Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
239 lines (216 loc) · 8.94 KB
/
Copy pathserver.js
File metadata and controls
239 lines (216 loc) · 8.94 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
'use strict';
const path = require('path');
const express = require('express');
const helmet = require('helmet');
const archiver = require('archiver');
const db = require('./src/db');
const { migrate } = require('./scripts/migrate');
const auth = require('./src/auth');
const render = require('./src/render');
const logos = require('./src/logos');
const certificates = require('./src/certificates');
const signatures = require('./src/signatures');
const approvals = require('./src/approvals');
const mailer = require('./src/mailer');
const PORT = process.env.PORT || 4321;
const ROOT = __dirname;
const ASSETS = path.join(ROOT, 'assets');
const PUBLIC = path.join(ROOT, 'public');
const app = express();
if (process.env.TRUST_PROXY) app.set('trust proxy', Number(process.env.TRUST_PROXY) || 1);
app.use(
helmet({
contentSecurityPolicy: false, // the preview iframe uses data: URIs + inline styles
crossOriginEmbedderPolicy: false,
})
);
app.use(express.json({ limit: '40mb' }));
app.use(auth.sessionMiddleware());
/* ----------------------------------------------------- public endpoints */
// Static frame art + fonts are not sensitive and are needed for the preview.
app.use('/assets', express.static(ASSETS));
// Front-end code (not sensitive); index.html is gated separately below.
app.use(express.static(PUBLIC, { index: false }));
app.get('/favicon.ico', (_req, res) => res.status(204).end());
app.get('/api/health', async (_req, res) => {
let dbOk = false;
try { await db.query('SELECT 1'); dbOk = true; } catch (_) { /* down */ }
res.status(dbOk ? 200 : 503).json({
ok: dbOk,
chrome: render.findChrome() || null,
db: dbOk,
// Whether notifications can go out is the kind of thing that quietly stops
// being true after a secret rotation, so it is on the health page.
mail: mailer.status(),
});
});
app.get('/unauthorized', (_req, res) => res.sendFile(path.join(PUBLIC, 'unauthorized.html')));
// Entra login / callback / logout.
auth.registerRoutes(app);
/* ------------------------------------------------------- signature signing */
/*
* The signing pages sit outside the app's authorization gate. An outside signer
* — a visiting lecturer, a partner organization — has no ACOR account and should
* not need one to countersign a certificate; their approval link is what
* authorizes them. Signers on an internal domain are a different matter and are
* made to sign in and match the address the request was sent to; that check lives
* in src/approvals.js, which is why attachIdentity runs here without requiring
* anything.
*/
app.get('/sign/:ref', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
// Keep the token out of the Referer header of anything the page loads.
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
res.sendFile(path.join(PUBLIC, 'sign.html'));
});
app.use('/api/sign', auth.attachIdentity, approvals.router());
/* -------------------------------------------------- authenticated app */
// The SPA shell — redirect to login if not signed in.
app.get('/', auth.requireAuth, (_req, res) => res.sendFile(path.join(PUBLIC, 'index.html')));
app.get('/api/me', auth.requireAuth, (req, res) => {
res.json({
id: req.user.id,
email: req.user.email,
name: req.user.display_name,
isAdmin: req.user.is_admin,
authDisabled: auth.AUTH_DISABLED,
// The editor offers to send a request itself only when it actually can.
mailEnabled: mailer.enabled(),
});
});
app.use('/api/logos', auth.requireAuth, logos.router());
app.use('/api/certificates', auth.requireAuth, certificates.router());
// A signer's own signature library — identity is enough, app access isn't needed.
// Same reasoning for the page itself: an ACOR signer who never creates
// certificates still needs somewhere to capture and keep their signature.
app.get('/profile', auth.requireIdentity, (_req, res) =>
res.sendFile(path.join(PUBLIC, 'profile.html'))
);
app.get('/api/profile/me', auth.requireIdentity, (req, res) => {
res.json({
id: req.identity.id,
email: req.identity.email,
name: req.identity.display_name,
appAccess: !!req.identity.app_access,
});
});
app.use('/api/signatures', auth.requireIdentity, signatures.router());
/*
* "Waiting for your signature": certificates where this person is a digital
* signer. Matched on the address the request was addressed to, so a signer who
* has an account never needs a link mailed to them at all.
*/
app.get('/api/awaiting', auth.requireIdentity, async (req, res) => {
const rows = await db.listAwaitingSignature(req.identity.email).catch(() => []);
const out = [];
for (const row of rows) {
const cert = await db.getCertificate(row.id).catch(() => null);
if (!cert) continue;
const state = approvals.status(cert);
const mine = state.signers.find((s) => s.id === row.signer_id);
if (!mine || mine.state === 'approved') continue; // nothing left to do
out.push({
certificateId: row.id,
signerId: row.signer_id,
title: row.title,
role: row.signer_role,
state: mine.state,
ownerName: row.owner_name,
ownerEmail: row.owner_email,
updatedAt: row.updated_at,
signUrl: `/sign/${row.signer_id}`,
});
}
res.json(out);
});
/* -------------------------------------------- ad-hoc "download unsaved" */
// Render straight from posted data (logos referenced by id, resolved to bytes).
app.post('/api/pdf', auth.requireAuth, async (req, res) => {
try {
const pages = await adhocPages(req.body);
const pdf = await render.renderPdf(pages);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${adhocName(req.body)}.pdf"`);
res.end(pdf);
} catch (err) {
console.error(err);
res.status(500).json({ error: String(err.message || err) });
}
});
app.post('/api/zip', auth.requireAuth, async (req, res) => {
try {
const pages = await adhocPages(req.body);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${adhocName(req.body)}.zip"`);
const archive = archiver('zip', { zlib: { level: 6 } });
archive.on('error', (e) => { console.error(e); try { res.status(500).end(); } catch (_) {} });
archive.pipe(res);
for (let i = 0; i < pages.length; i++) {
const pdf = await render.renderPdf([pages[i]]);
archive.append(Buffer.from(pdf), { name: render.safeFile(pages[i].studentName, i) });
}
await archive.finalize();
} catch (err) {
console.error(err);
if (!res.headersSent) res.status(500).json({ error: String(err.message || err) });
}
});
/*
* Build render pages from a posted (unsaved) definition. Logos may be given as
* logoIds (resolved from the DB) and/or raw data URIs in `logos`.
*
* Signatures are stripped here, deliberately and unconditionally. This endpoint
* renders whatever it is handed, so honouring a posted signature would let the
* owner of a certificate print one that nobody approved — and skip the DRAFT
* watermark while doing it. An unsaved certificate has no approvals by
* definition, so a digital signer here means a blank line and a DRAFT stamp.
* Saved certificates download through /api/certificates/:id/pdf, where the
* server decides what has really been signed.
*/
async function adhocPages(body) {
const base = body.base || {};
const fromIds = await render.resolveLogoDataUris(base.logoIds || []);
const raw = Array.isArray(base.logos) ? base.logos.filter(Boolean) : [];
const signers = (Array.isArray(base.signers) ? base.signers : []).map((s) => ({
name: (s && s.name) || '',
role: (s && s.role) || '',
org: (s && s.org) || '',
signature: null,
}));
const awaitingSignature = (Array.isArray(base.signers) ? base.signers : []).some(
(s) => s && s.mode === 'digital'
);
return render.expand(
{
courseTitle: base.courseTitle || base.title || '',
certType: base.certType,
presentedTo: base.presentedTo,
participation: base.participation,
infoLines: base.infoLines || '',
signers,
draft: awaitingSignature,
logos: [...fromIds, ...raw],
},
body.students || []
);
}
function adhocName(body) {
const base = String(body.filename || 'certificates').replace(/[^\w\-]+/g, '_') || 'certificates';
const digital = ((body.base || {}).signers || []).some((s) => s && s.mode === 'digital');
return digital ? `${base}_DRAFT` : base;
}
/* ----------------------------------------------------------------- boot */
async function start() {
await migrate();
app.listen(PORT, () => {
console.log(`\n ACOR Certifier running: http://localhost:${PORT}`);
console.log(` Chrome: ${render.findChrome() || 'NOT FOUND (set PUPPETEER_EXECUTABLE_PATH)'}`);
console.log(` Auth: ${auth.AUTH_DISABLED ? 'DISABLED (dev)' : 'Entra ID'}`);
console.log(` Mail: ${mailer.status()}\n`);
});
}
start().catch((e) => {
console.error('Failed to start:', e.message);
process.exit(1);
});