app.ts

1#!/usr/bin/env node
2
3import { ArgParser, Arg } from './classes/ArgParser';
4import { firstTimeSetup, isFirstTimeSetup } from './utils/system';
5import express, { Request, Response } from 'express';
6import { join as pJoin } from 'node:path';
7import { render } from './wwwroot';
8import session from 'express-session';
9import AppDB from './classes/AppDB';
10import { createReadStream, existsSync, statSync, unlinkSync } from 'node:fs';
11import {
12	generateId,
13	timeDifference,
14	picDir,
15	storageForAvatars,
16	storageForImages,
17	storageForVideos,
18	makeUUID,
19} from './utils/fileUtils';
20import multer from 'multer';
21import bcrypt from 'bcrypt';
22import os from 'os';
23import rateLimit from 'express-rate-limit';
24import constants from './constants';
25import { config as dotenv } from 'dotenv';
26import NodeCache from 'node-cache';
27import RequestWithSession from './interfaces/RequestWithSession';
28import { reminders } from './utils/reminder';
29import { makeBot, checkIfBotExists, makeBotAvatar } from './utils/botAccounts';
30import logger from './middleware/logger';
31
32const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
33
34const uploadForImages = multer({ storage: storageForImages });
35const uploadForAvatars = multer({ storage: storageForAvatars });
36const uploadForVideos = multer({ storage: storageForVideos });
37const server = express();
38
39const postCache = new NodeCache({
40	// 10 minutes
41	stdTTL: 600,
42});
43
44async function main(args: Arg[]) {
45	const envPath = args.find((arg) => arg.name === 'env')?.value;
46
47	dotenv({
48		path: envPath || pJoin(__dirname, '..', '.env'),
49	});
50
51	const db = AppDB.getInstance();
52
53	const port = args.find((arg) => arg.name === 'port')?.value || 3000;
54	const host = args.find((arg) => arg.name === 'host')?.value || 'localhost';
55
56	const validHosts = ['localhost', '127.0.0.1', '0.0.0.0'];
57	if (!validHosts.includes(host)) {
58		console.error(
59			'Invalid host provided. Please provide a valid host.',
60			validHosts.join(', '),
61		);
62		process.exit(1);
63	}
64
65	server.use(express.json());
66	server.use(express.urlencoded({ extended: true }));
67	server.use(express.static(pJoin(__dirname, 'wwwroot', 'public')));
68	server.set('view engine', 'ejs');
69	server.set('views', pJoin(__dirname, 'wwwroot', 'views'));
70	server.set('trust proxy', true);
71	server.use(
72		session({
73			secret: 'i-love-catgirls',
74			resave: false,
75			saveUninitialized: true,
76			cookie: {
77				secure: process.env.NODE_ENV !== 'development',
78				maxAge: 1000 * 60 * 60 * 24, // 24 hours
79			},
80		}),
81	);
82	server.use(logger);
83
84	server.get('/', async (req: RequestWithSession, res: Response) => {
85		const media = await db.statement(
86			'SELECT * FROM Media ORDER BY UploadedAt DESC',
87		);
88
89		const deleteSuccess = req.query.deleted === 'true';
90		const id = req.query.id;
91
92		render(req, res, 'index', {
93			title: 'Home',
94			media,
95			timeDifference,
96			deleteSuccess,
97			deletedId: deleteSuccess ? id : null,
98		});
99	});
100
101	server.get('/login', (req: Request, res: Response) => {
102		const returnUrl = req.query.returnTo || null;
103		render(req, res, 'login', {
104			title: 'Login',
105			error: req.query.error || null,
106			logout: !!req.query.logout,
107			returnUrl,
108		});
109	});
110
111	server.get('/view/:id', async (req: RequestWithSession, res: Response) => {
112		const imageId = req.params.id;
113		const raw = req.query.raw === 'true';
114
115		const media = await db.statement('SELECT * FROM Media WHERE ID = ?', [
116			imageId,
117		]);
118
119		if (media.length === 0) {
120			return res.status(404).send('Media not found');
121		}
122
123		if (!raw) {
124			await db.statement('UPDATE Media SET Views = Views + 1 WHERE ID = ?', [
125				imageId,
126			]);
127
128			const uploadedBy = await db.statement(
129				'SELECT * FROM Admins WHERE ID = ?',
130				[media[0].UploadedBy],
131			);
132
133			if (uploadedBy.length === 0) {
134				return res.status(404).send('Uploader not found');
135			}
136			return render(req, res, 'view', {
137				title: `${media[0].Caption}`,
138				image: media[0],
139				link: `https://${req.get('host')}/view/${imageId}?raw=true`,
140				canDelete:
141					!!req.session.user && req.session.user.Id === media[0].UploadedBy,
142				uploadedBy: uploadedBy[0],
143			});
144		} else {
145			const type = media[0].ContentType.split('/')[0];
146			const mediaPath = pJoin(
147				picDir[type as keyof typeof picDir],
148				media[0].FileName,
149			);
150
151			if (!existsSync(mediaPath)) {
152				return res.status(404).send('Media not found');
153			}
154
155			try {
156				const stats = statSync(mediaPath);
157				const range = req.headers.range;
158
159				if (range) {
160					const [start, end] = range
161						.replace(/bytes=/, '')
162						.split('-')
163						.map(Number);
164					const fileSize = stats.size;
165					const chunkStart = Math.max(start, 0);
166					const chunkEnd = Math.min(end || fileSize - 1, fileSize - 1);
167
168					const contentLength = chunkEnd - chunkStart + 1;
169
170					res.writeHead(206, {
171						'Content-Range': `bytes ${chunkStart}-${chunkEnd}/${fileSize}`,
172						'Accept-Ranges': 'bytes',
173						'Content-Length': contentLength,
174						'Content-Type': media[0].ContentType,
175						'Content-Disposition': `inline; filename="${media[0].FileName}.${media[0].ContentType.split('/')[1]}"`,
176					});
177
178					const stream = createReadStream(mediaPath, {
179						start: chunkStart,
180						end: chunkEnd,
181					});
182					stream.pipe(res);
183				} else {
184					res.writeHead(200, {
185						'Content-Length': stats.size,
186						'Content-Type': media[0].ContentType,
187					});
188
189					const stream = createReadStream(mediaPath);
190					stream.pipe(res);
191				}
192			} catch (error) {
193				if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
194					return res.status(404).send('Media not found');
195				}
196
197				console.error('Error serving media:', error);
198				return res.status(500).send('Internal Server Error');
199			}
200		}
201	});
202
203	server.get('/stats', async (req: Request, res: Response) => {
204		let totalStorage = 0;
205
206		const images = await db.statement('SELECT * FROM Media');
207		const admins = await db.statement('SELECT * FROM Admins');
208
209		images.forEach((image: any) => {
210			const type = image.ContentType.split('/')[0];
211			const imagePath = pJoin(
212				picDir[type as keyof typeof picDir],
213				image.FileName,
214			);
215			const stats = statSync(imagePath);
216			totalStorage += stats.size;
217		});
218
219		admins.forEach((admin: any) => {
220			const imagePath = pJoin(picDir.avatar, admin.ProfilePicture);
221			const stats = statSync(imagePath);
222			totalStorage += stats.size;
223		});
224
225		function formatBytes(bytes: number, decimals = 2) {
226			if (bytes === 0) return '0 Bytes';
227			const k = 1024;
228			const dm = decimals < 0 ? 0 : decimals;
229			const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
230			const i = Math.floor(Math.log(bytes) / Math.log(k));
231			return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
232		}
233
234		const memoryUsage = process.memoryUsage();
235		const totalMemory = os.totalmem();
236		const freeMemory = os.freemem();
237
238		const stats = {
239			storage: {
240				title: 'Storage Used (Images, Avatars, Videos)',
241				value: formatBytes(totalStorage),
242			},
243			images: {
244				title: 'Total Media',
245				value: images.length,
246			},
247			memory: {
248				title: 'Memory Usage',
249				value: `${formatBytes(memoryUsage.rss)} / ${formatBytes(memoryUsage.heapUsed)} / ${formatBytes(memoryUsage.external)}`,
250			},
251			memorySystem: {
252				title: 'System Memory',
253				value: `${formatBytes(totalMemory - freeMemory)} used / ${formatBytes(freeMemory)} free`,
254			},
255		};
256
257		render(req, res, 'stats', {
258			title: 'Stats',
259			stats,
260		});
261	});
262
263	server.get(
264		'/avatars/:fileName',
265		async (req: RequestWithSession, res: Response) => {
266			const fileName = req.params.fileName;
267
268			// Redirect to a default image if the requested filename is 'default.jpg'
269			if (fileName === 'default.jpg') {
270				return res.redirect('/assets/default.jpg');
271			}
272
273			const imagePath = pJoin(picDir.avatar, fileName);
274
275			// Check if the file exists
276			if (!existsSync(imagePath)) {
277				return res.status(404).send('Image not found');
278			}
279
280			try {
281				// Use res.sendFile to serve the file without manually creating a read stream
282				res.sendFile(imagePath, (err) => {
283					if (err) {
284						console.error('Error sending image file:', err);
285						res.status(500).send('Internal Server Error');
286					}
287				});
288			} catch (error) {
289				console.error('Error serving image:', error);
290				res.status(500).send('Internal Server Error');
291			}
292		},
293	);
294
295	server.get(
296		'/admins/:username?',
297		async (req: RequestWithSession, res: Response) => {
298			const adminUsername = req.params.username;
299
300			if (!adminUsername) {
301				const admins = await db.statement('SELECT * FROM Admins');
302
303				return render(req, res, 'admins', {
304					title: 'Admins',
305					admins,
306				});
307			}
308
309			const admin = await db.statement(
310				'SELECT * FROM Admins WHERE Username = ?',
311				[adminUsername],
312			);
313
314			if (admin.length === 0) {
315				return res.status(404).send('Admin not found');
316			}
317
318			const recentUploads = await db.statement(
319				'SELECT * FROM Media WHERE UploadedBy = ? ORDER BY UploadedAt DESC LIMIT 5',
320				[admin[0].Id],
321			);
322			const allUploads = await db.statement(
323				'SELECT * FROM Media WHERE UploadedBy = ?',
324				[admin[0].Id],
325			);
326
327			render(req, res, 'admin', {
328				title: `Admin ${admin[0].Username}`,
329				user: admin[0],
330				link: `https://${req.get('host')}/admins/${admin[0].Username}`,
331				recentUploads,
332				timeDifference,
333				allUploads,
334				avatarType: admin[0].ProfilePicture.split('.').pop(),
335			});
336		},
337	);
338
339	server.get('/settings', async (req: RequestWithSession, res: Response) => {
340		if (!req.session.user) {
341			return res.redirect('/login?returnTo=/settings');
342		}
343
344		render(req, res, 'settings', {
345			title: 'Settings',
346			user: req.session.user,
347			error: null,
348		});
349	});
350
351	server.post(
352		'/settings',
353		uploadForAvatars.single('avatar'),
354		async (req: RequestWithSession, res: Response) => {
355			if (!req.session.user) {
356				return res.redirect('/login?returnTo=/settings');
357			}
358
359			if (req.file && !req.file.mimetype.startsWith('image/')) {
360				return res
361					.status(400)
362					.send('Invalid file type. Only images are allowed.');
363			}
364
365			const { username, bio } = req.body;
366
367			if (!username || !bio) {
368				return res.status(400).send('Username and bio are required.');
369			}
370
371			try {
372				const userId = req.session.user.Id;
373				let profilePicture = req.session.user.ProfilePicture;
374
375				if (req.file) {
376					profilePicture = req.file.filename;
377				}
378
379				if (req.file && req.session.user.ProfilePicture !== 'default.jpg') {
380					const oldAvatarPath = pJoin(
381						picDir.avatar,
382						req.session.user.ProfilePicture,
383					);
384					const exists = existsSync(oldAvatarPath);
385					if (exists) {
386						unlinkSync(oldAvatarPath);
387					}
388				}
389
390				await db.statement(
391					'UPDATE Admins SET ProfilePicture = ?, Bio = ?, Username = ? WHERE Id = ?',
392					[profilePicture, bio, username, userId],
393				);
394
395				req.session.user.Username = username;
396				req.session.user.Bio = bio;
397				if (req.file) {
398					req.session.user.ProfilePicture = profilePicture;
399				}
400
401				res.redirect('/settings');
402			} catch (error) {
403				console.error('Error updating settings:', error);
404				res.status(500).send('Internal Server Error');
405			}
406		},
407	);
408
409	server.get('/delete/:id', async (req: RequestWithSession, res: Response) => {
410		if (!req.session.user) {
411			return res.redirect(`/login?returnTo=/delete/${req.params.id}`);
412		}
413
414		const imageId = req.params.id;
415
416		const image = await db.statement('SELECT * FROM Media WHERE ID = ?', [
417			imageId,
418		]);
419
420		if (image.length === 0) {
421			return res.status(404).send('Media not found');
422		}
423
424		const type = image[0].ContentType.split('/')[0];
425		const imagePath = pJoin(
426			picDir[type as keyof typeof picDir],
427			image[0].FileName,
428		);
429
430		const exists = existsSync(imagePath);
431
432		if (!exists) {
433			return res.status(404).send('Media not found');
434		}
435
436		try {
437			await db.statement('DELETE FROM Media WHERE ID = ?', [imageId]);
438			unlinkSync(imagePath);
439			res.redirect('/?deleted=true&id=' + imageId);
440		} catch (error) {
441			console.error('Error deleting image:', error);
442			res.status(500).send('Internal Server Error');
443		}
444	});
445
446	server.post('/login', async (req: RequestWithSession, res: Response) => {
447		const { username, password } = req.body;
448		const returnUrl = req.query.returnTo || null;
449
450		if (!username || !password) {
451			return render(req, res, 'login', {
452				title: 'Login',
453				error: 'Please provide a username and password',
454				logout: false,
455				returnUrl,
456			});
457		}
458
459		const user = await db.statement('SELECT * FROM Admins WHERE Username = ?', [
460			username,
461		]);
462
463		if (
464			user.length === 0 ||
465			!(await bcrypt.compare(password, user[0].Password))
466		) {
467			return render(req, res, 'login', {
468				title: 'Login',
469				error: 'Invalid username or password',
470				logout: false,
471				returnUrl,
472			});
473		}
474
475		req.session.user = user[0];
476		res.redirect(returnUrl ? (returnUrl as string) : '/');
477	});
478
479	server.get('/register', (req: Request, res: Response) => {
480		const returnUrl = req.query.returnTo || null;
481		render(req, res, 'register', {
482			title: 'Register',
483			error: null,
484			returnUrl,
485		});
486	});
487
488	server.post('/register', async (req, res) => {
489		const { username, password, masterKey } = req.body;
490		const returnUrl = req.query.returnTo || null;
491
492		const pMasterKey = process.env.MASTER_KEY;
493
494		if (!username || !password || !masterKey) {
495			return render(req, res, 'register', {
496				title: 'Register',
497				error: 'Please provide a username, password, and master key.',
498				returnUrl,
499			});
500		}
501
502		if (masterKey.trim() !== pMasterKey) {
503			return render(req, res, 'register', {
504				title: 'Register',
505				error: 'Invalid master key.',
506				returnUrl,
507			});
508		}
509
510		try {
511			const userExists = await db.statement(
512				'SELECT * FROM Admins WHERE Username = ?',
513				[username.toLowerCase().trim()],
514			);
515			if (userExists.length > 0) {
516				return render(req, res, 'register', {
517					title: 'Register',
518					error: 'Username already exists.',
519					returnUrl,
520				});
521			}
522
523			const hashedPassword = await bcrypt.hash(password, 10);
524
525			await db.statement(
526				'INSERT INTO Admins (Username, Password, ProfilePicture, Bio, Role) VALUES (?, ?, ?, ?, ?)',
527				[
528					username.toLowerCase().trim(),
529					hashedPassword,
530					'default.jpg',
531					`Hello, I'm ${username}!`,
532					'user',
533				],
534			);
535
536			res.redirect('/login');
537		} catch (error) {
538			console.error('Error registering user:', error);
539			res.status(500).send('Internal Server Error');
540		}
541	});
542
543	server.get('/upload/:type?', (req: RequestWithSession, res: Response) => {
544		if (!req.session.user) {
545			return res.redirect('/login?returnTo=/upload');
546		}
547
548		if (req.params.type && !['image', 'video'].includes(req.params.type)) {
549			return res.status(400).send('Invalid type');
550		}
551
552		render(req, res, `upload-${req.params.type || 'image'}`, {
553			title: 'Upload',
554			error: req.query.error || null,
555		});
556	});
557
558	server.post('/upload-image', (req: RequestWithSession, res: Response) => {
559		if (!req.session.user) {
560			return res.redirect('/login?returnTo=/upload');
561		}
562
563		uploadForImages.single('image')(req, res, async (err: any) => {
564			if (err) {
565				console.error('Error uploading image:', err);
566				return res.status(500).send('Internal Server Error');
567			}
568
569			if (!req.file) {
570				return res.status(400).send('Bad Request');
571			}
572
573			const { caption } = req.body;
574			const id = generateId();
575			const contentType = req.file.mimetype;
576			const currentDate = new Date();
577			const uploadedBy = req.session.user.Id;
578
579			try {
580				await db.statement(
581					'INSERT INTO Media (Id, FileName, Caption, ContentType, Views, UploadedAt, UploadedBy) VALUES (?, ?, ?, ?, ?, ?, ?)',
582					[
583						id,
584						req.file.filename,
585						caption,
586						contentType,
587						0,
588						currentDate,
589						uploadedBy,
590					],
591				);
592				res.redirect(`/view/${id}`);
593			} catch (error) {
594				console.error('Error uploading image:', error);
595				res.status(500).send('Internal Server Error');
596			}
597		});
598	});
599
600	server.post('/upload-video', (req: RequestWithSession, res: Response) => {
601		if (!req.session.user) {
602			return res.redirect('/login?returnTo=/upload/video');
603		}
604
605		uploadForVideos.single('video')(req, res, async (err: any) => {
606			if (err) {
607				console.error('Error uploading video:', err);
608				return res.status(500).send('Internal Server Error');
609			}
610
611			if (!req.file) {
612				return res.status(400).send('Bad Request');
613			}
614
615			const { caption } = req.body;
616			const id = generateId();
617			const contentType = req.file.mimetype;
618			const currentDate = new Date();
619			const uploadedBy = req.session.user.Id;
620
621			try {
622				await db.statement(
623					'INSERT INTO Media (Id, FileName, Caption, ContentType, Views, UploadedAt, UploadedBy) VALUES (?, ?, ?, ?, ?, ?, ?)',
624					[
625						id,
626						req.file.filename,
627						caption,
628						contentType,
629						0,
630						currentDate,
631						uploadedBy,
632					],
633				);
634				res.redirect(`/view/${id}`);
635			} catch (error) {
636				console.error('Error uploading video:', error);
637				res.status(500).send('Internal Server Error');
638			}
639		});
640	});
641
642	server.get('/logout', (req: RequestWithSession, res: Response) => {
643		if (!req.session.user) {
644			return res.redirect('/login');
645		}
646		req.session.destroy((err: any) => {
647			if (err) {
648				console.error('Error destroying session:', err);
649			}
650			res.redirect('/login?logout=true');
651		});
652	});
653
654	server.get('/api/get-media', async (req: Request, res: Response) => {
655		const by = req.query.by as string;
656		const sort = req.query.sort as string;
657		const order = req.query.order as string;
658		const limit = req.query.limit as string;
659		const type = req.query.type as string;
660
661		if (!by) {
662			return res.status(400).json({
663				error: "Missing 'by' query parameter. This must be a user ID.",
664			});
665		}
666
667		const sortValid = ['ASC', 'DESC'];
668		const orderValid = ['UploadedAt', 'Views'];
669
670		let query = 'SELECT * FROM Media WHERE UploadedBy = ?';
671		const queryParams: any[] = [by];
672
673		let orderByClause = '';
674		if (order && orderValid.includes(order)) {
675			if (order === 'Views') {
676				orderByClause = ` ORDER BY Views DESC`;
677			} else {
678				const sortDirection =
679					sort && sortValid.includes(sort.toUpperCase()) ?
680						sort.toUpperCase()
681					:	'ASC';
682				orderByClause = ` ORDER BY ${order} ${sortDirection}`;
683			}
684		}
685
686		if (orderByClause) {
687			query += orderByClause;
688		}
689
690		if (limit) {
691			const limitInt = parseInt(limit, 10);
692			if (isNaN(limitInt) || limitInt <= 0) {
693				return res.status(400).json({
694					error:
695						"Invalid 'limit' query parameter. It must be a positive integer.",
696				});
697			}
698			query += ` LIMIT ?`;
699			queryParams.push(limitInt);
700		}
701
702		try {
703			if (postCache.has(query)) {
704				const cached = postCache.get(query);
705				return res.json(cached);
706			}
707
708			let images = await db.statement(query, queryParams);
709
710			query += ` AND ContentType LIKE ?`;
711			queryParams.push(`${type === 'videos' ? 'video' : 'image'}%`);
712
713			postCache.set(query, images);
714			res.json(images);
715		} catch (error) {
716			console.error('Error fetching media:', error);
717			res.status(500).json({ error: 'Internal Server Error' });
718		}
719	});
720
721	server.listen(port, host, async () => {
722		if (isFirstTimeSetup()) {
723			await firstTimeSetup();
724		}
725
726		// Give the database some time to start up
727		sleep(3000);
728
729		const botExists = await checkIfBotExists('Futaba_Anzu');
730		if (!botExists) {
731			if (!process.env.DEFAULT_BOT_PASSWORD) {
732				console.error(
733					'No default bot password provided. Please provide a password for the default bot account in the environment variables.',
734				);
735				process.exit(1);
736			}
737
738			const defaultAvatarPath = pJoin(
739				'src',
740				'wwwroot',
741				'public',
742				'assets',
743				'bot-avatars',
744				'futaba_anzu.jpg',
745			);
746
747			if (!existsSync(defaultAvatarPath)) {
748				console.error('Default bot avatar not found.');
749				process.exit(1);
750			}
751
752			const uuid = makeUUID();
753
754			console.log('Creating bot account to help with utility tasks.');
755
756			await makeBot({
757				username: 'Futaba_Anzu',
758				password: process.env.DEFAULT_BOT_PASSWORD,
759				bio: 'Hello! I am Futaba Anzu, a bot account.',
760				avatar: uuid,
761			});
762
763			await makeBotAvatar('Futaba_Anzu', uuid, defaultAvatarPath);
764		}
765		console.log(`Server running at http://${host}:${port}`);
766		console.log(`Serving images from ${picDir.base}`);
767		reminders.start();
768		console.log('Press Ctrl+C to stop the server');
769	});
770}
771
772const argParser = new ArgParser(process.argv.slice(2));
773
774main(argParser.parse());
775