Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
350 lines (303 loc) · 13.1 KB
/
Copy pathserver.js
File metadata and controls
350 lines (303 loc) · 13.1 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
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const express = require('express');
const session = require('express-session');
const multer = require('multer');
const pdfParse = require('pdf-parse');
const archiver = require('archiver');
const { authenticator } = require('otplib');
const { parseResumeText } = require('./lib/resumeParser');
const app = express();
const PORT = process.env.PORT || 3000;
// Explicit 0.0.0.0 rather than relying on the default, since some
// container/panel hosts (Docker, Pterodactyl) need the server bound to
// all interfaces, not just localhost, to be reachable from outside.
const HOST = process.env.HOST || '0.0.0.0';
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
const SESSION_SECRET = process.env.SESSION_SECRET;
// Optional — set this (see scripts/generate-2fa-secret.js) to require a
// TOTP code at login. Leave it unset and login works with just a
// username/password, same as before.
const ADMIN_TOTP_SECRET = process.env.ADMIN_TOTP_SECRET || '';
if (!ADMIN_PASSWORD || !SESSION_SECRET) {
console.error(
'\n[ERROR] Missing ADMIN_PASSWORD or SESSION_SECRET.\n' +
'Copy .env.example to .env and fill both in before starting the server.\n'
);
process.exit(1);
}
const DATA_DIR = path.join(__dirname, 'data');
const CONTENT_PATH = path.join(DATA_DIR, 'content.json');
const UPLOADS_DIR = path.join(__dirname, 'public', 'uploads');
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
// ---------------------------------------------------------------------------
// Content read/write helpers (JSON file = the whole "database")
// ---------------------------------------------------------------------------
function readContent() {
return JSON.parse(fs.readFileSync(CONTENT_PATH, 'utf8'));
}
function writeContent(data) {
// Basic shape check so a malformed save can't corrupt the file the public
// site depends on.
const required = ['theme', 'name', 'role', 'tagline', 'status', 'about', 'projects', 'resume', 'education', 'contact', 'footerNote', 'customSections', 'testimonials'];
for (const key of required) {
if (!(key in data)) throw new Error(`Missing required field: ${key}`);
}
fs.writeFileSync(CONTENT_PATH, JSON.stringify(data, null, 2));
}
// ---------------------------------------------------------------------------
// Core middleware
// ---------------------------------------------------------------------------
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
// Needed so secure cookies work correctly behind a host's HTTPS proxy
// (Render, Railway, Fly, etc.) once NODE_ENV=production is set.
app.set('trust proxy', 1);
app.use(session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 1000 * 60 * 60 * 8, // 8 hours
},
}));
function requireAuth(req, res, next) {
if (req.session && req.session.isAdmin) return next();
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Not authenticated' });
return res.redirect('/admin/login.html');
}
// ---------------------------------------------------------------------------
// Public site + public content API
// ---------------------------------------------------------------------------
app.use(express.static(path.join(__dirname, 'public')));
app.get('/api/content', (req, res) => {
try {
res.set('Cache-Control', 'no-store');
res.json(readContent());
} catch (err) {
res.status(500).json({ error: 'Could not load content' });
}
});
// ---------------------------------------------------------------------------
// Admin auth
// ---------------------------------------------------------------------------
// Constant-time string comparison so login checks don't leak timing info
// about how much of the username/password was correct.
function safeEqual(a, b) {
const bufA = Buffer.from(String(a));
const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) {
// Still run a comparison (against itself) so this branch takes
// roughly the same time as a length-matched one.
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
app.post('/api/admin/login', (req, res) => {
const { username, password } = req.body || {};
const validCreds =
typeof username === 'string' &&
typeof password === 'string' &&
safeEqual(username, ADMIN_USERNAME) &&
safeEqual(password, ADMIN_PASSWORD);
if (!validCreds) {
return res.status(401).json({ error: 'Incorrect username or password' });
}
if (ADMIN_TOTP_SECRET) {
// Credentials check out, but 2FA is enabled — don't grant access yet.
req.session.pendingAdmin = true;
req.session.isAdmin = false;
return res.json({ ok: true, requires2FA: true });
}
req.session.isAdmin = true;
res.json({ ok: true, requires2FA: false });
});
app.post('/api/admin/verify-2fa', (req, res) => {
if (!req.session.pendingAdmin) {
return res.status(401).json({ error: 'Log in with your username and password first' });
}
const { code } = req.body || {};
let valid = false;
try {
valid =
typeof code === 'string' &&
!!ADMIN_TOTP_SECRET &&
authenticator.check(code.replace(/\s+/g, ''), ADMIN_TOTP_SECRET);
} catch (err) {
valid = false;
}
if (!valid) {
return res.status(401).json({ error: 'Incorrect or expired code' });
}
req.session.pendingAdmin = false;
req.session.isAdmin = true;
res.json({ ok: true });
});
app.post('/api/admin/logout', (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
app.get('/api/admin/session', (req, res) => {
res.json({ isAdmin: !!(req.session && req.session.isAdmin) });
});
// Admin panel page + its assets
app.get('/admin/login.html', (req, res) => {
res.sendFile(path.join(__dirname, 'admin', 'login.html'));
});
// no-store here so admin CSS/JS updates always take effect on refresh,
// with no risk of the browser serving a stale cached copy while iterating.
app.use('/admin/assets', express.static(path.join(__dirname, 'admin', 'assets'), {
setHeaders: (res) => res.set('Cache-Control', 'no-store'),
}));
app.get('/admin', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, 'admin', 'panel.html'));
});
// ---------------------------------------------------------------------------
// Admin content API (all require auth)
// ---------------------------------------------------------------------------
app.get('/api/admin/content', requireAuth, (req, res) => {
try {
res.json(readContent());
} catch (err) {
res.status(500).json({ error: 'Could not load content' });
}
});
app.post('/api/admin/content', requireAuth, (req, res) => {
try {
writeContent(req.body);
res.json({ ok: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// File uploads (photo + resume PDF + per-project image/video)
const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
const VIDEO_EXTS = ['.mp4', '.webm', '.mov'];
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOADS_DIR),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`);
},
}),
// No size limit — you asked for unrestricted video uploads. Be aware this
// means a very large file will use a lot of disk space and take a while
// to upload; there's nothing here stopping that.
fileFilter: (req, file, cb) => {
const kind = req.query.kind;
const ext = path.extname(file.originalname).toLowerCase();
if (kind === 'resume') {
if (ext !== '.pdf') return cb(new Error('Résumé must be a PDF file'));
} else if (kind === 'media') {
if (![...IMAGE_EXTS, ...VIDEO_EXTS].includes(ext)) {
return cb(new Error('Project media must be a JPG, PNG, WEBP, MP4, WEBM, or MOV file'));
}
} else {
if (!IMAGE_EXTS.includes(ext)) return cb(new Error('Photo must be a JPG, PNG, or WEBP file'));
}
cb(null, true);
},
});
app.post('/api/admin/upload', requireAuth, (req, res) => {
upload.single('file')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file received' });
const ext = path.extname(req.file.filename).toLowerCase();
const mediaType = VIDEO_EXTS.includes(ext) ? 'video' : 'image';
res.json({ url: `/uploads/${req.file.filename}`, mediaType });
});
});
// Best-effort résumé autofill: reads a previously-uploaded PDF and pulls out
// a draft summary/skills/experience for the admin panel to review.
app.post('/api/admin/parse-resume', requireAuth, async (req, res) => {
try {
const { url } = req.body || {};
if (typeof url !== 'string' || !/^\/uploads\/[\w.-]+\.pdf$/i.test(url)) {
return res.status(400).json({ error: 'Invalid file reference' });
}
const filePath = path.resolve(path.join(__dirname, 'public', url));
if (!filePath.startsWith(UPLOADS_DIR) || !fs.existsSync(filePath)) {
return res.status(400).json({ error: 'File not found' });
}
const buffer = fs.readFileSync(filePath);
const parsed = await pdfParse(buffer);
if (!parsed.text || !parsed.text.trim()) {
return res.status(422).json({ error: 'No readable text found in that PDF — it may be a scanned image rather than real text.' });
}
res.json({ draft: parseResumeText(parsed.text) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Could not read that PDF.' });
}
});
// ---------------------------------------------------------------------------
// Static export — bakes the currently-SAVED content directly into a copy of
// the public site (no server, no /api calls at runtime) so it can be
// dropped onto Netlify, Vercel, GitHub Pages, or any other static host.
// This always exports what's on disk, i.e. your last "Save changes" — not
// whatever's sitting unsaved in the admin form.
const EXPORT_README = `# Static export of your portfolio
This is a self-contained snapshot — plain HTML/CSS/JS, no server, no
database, no admin panel. It reflects whatever was saved at the moment you
exported it; editing it again means going back to the admin panel, making
changes, saving, and exporting a fresh copy.
## Deploying
Any static host works, since this is just files:
- **Netlify**: drag this whole folder onto app.netlify.com/drop, or connect
a repo containing it and set the publish directory to this folder.
- **Vercel**: \`vercel deploy\` from inside this folder, or drag-and-drop
via the dashboard the same way as Netlify.
- **GitHub Pages**: push this folder's contents to a repo (or a \`docs/\`
folder / \`gh-pages\` branch) and enable Pages in the repo's settings.
- **Anywhere else**: any host that serves static files works — there's
nothing here that needs Node.js or a database.
## What's included
- \`index.html\`, \`style.css\`, \`script.js\` — the site itself, with your
content already embedded in \`index.html\`
- \`uploads/\` — your photo, résumé PDF, and any project images/videos
## The contact form
It still works the same way it does on the live site: it opens the
visitor's email client with the message pre-filled. Nothing to configure.
`;
app.get('/api/admin/export', requireAuth, (req, res) => {
try {
const content = readContent();
let indexHtml = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
const embed = `<script>window.__PORTFOLIO_CONTENT__ = ${JSON.stringify(content)};</script>\n`;
if (!indexHtml.includes('<script src="script.js"></script>')) {
throw new Error('Could not find the script tag to embed content next to — public/index.html may have changed.');
}
indexHtml = indexHtml.replace('<script src="script.js"></script>', `${embed}<script src="script.js"></script>`);
res.set('Content-Type', 'application/zip');
res.set('Content-Disposition', 'attachment; filename="portfolio-static-export.zip"');
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('error', (err) => {
console.error(err);
if (!res.headersSent) res.status(500).end();
});
archive.pipe(res);
archive.append(indexHtml, { name: 'index.html' });
archive.file(path.join(__dirname, 'public', 'style.css'), { name: 'style.css' });
archive.file(path.join(__dirname, 'public', 'script.js'), { name: 'script.js' });
archive.append(EXPORT_README, { name: 'README.md' });
if (fs.existsSync(UPLOADS_DIR)) {
archive.directory(UPLOADS_DIR, 'uploads');
}
archive.finalize();
} catch (err) {
console.error(err);
if (!res.headersSent) res.status(500).json({ error: 'Export failed' });
}
});
// ---------------------------------------------------------------------------
app.listen(PORT, HOST, () => {
console.log(`Portfolio running at http://localhost:${PORT}`);
console.log(`Admin panel at http://localhost:${PORT}/admin`);
});