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