This commit is contained in:
Pitu 2021-06-24 02:07:07 +09:00
parent af0e46e552
commit b924fbd314
19 changed files with 1036 additions and 994 deletions

84
package-lock.json generated
View File

@ -61,15 +61,20 @@
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@nuxt/types": "2.15.7",
"@types/adm-zip": "^0.4.34",
"@types/bcryptjs": "^2.4.2",
"@types/cors": "^2.8.10",
"@types/cron": "^1.7.2",
"@types/express": "^4.17.11",
"@types/express-rate-limit": "^5.1.2",
"@types/fluent-ffmpeg": "^2.1.17",
"@types/jsonwebtoken": "^8.5.1",
"@types/morgan": "^1.9.2",
"@types/node": "^14.14.41",
"@types/randomstring": "^1.1.6",
"@types/sharp": "^0.27.1",
"@types/systeminformation": "^3.54.1",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"@vue/test-utils": "^1.2.1",
@ -3797,6 +3802,15 @@
"node": ">= 6"
}
},
"node_modules/@types/adm-zip": {
"version": "0.4.34",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.4.34.tgz",
"integrity": "sha512-8ToYLLAYhkRfcmmljrKi22gT2pqu7hGMDtORP1emwIEGmgUTZOsaDjzWFzW5N2frcFRz/50CWt4zA1CxJ73pmQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/anymatch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
@ -3997,6 +4011,15 @@
"@types/webpack": "^4"
}
},
"node_modules/@types/fluent-ffmpeg": {
"version": "2.1.17",
"resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.17.tgz",
"integrity": "sha512-/bdvjKw/mtBHlJ2370d04nt4CsWqU5MrwS/NtO96V01jxitJ4+iq8OFNcqc5CegeV3TQOK3uueK02kvRK+zjUg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/glob": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
@ -4163,6 +4186,12 @@
"integrity": "sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==",
"dev": true
},
"node_modules/@types/randomstring": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@types/randomstring/-/randomstring-1.1.6.tgz",
"integrity": "sha512-XRIZIMTxjcUukqQcYBdpFWGbcRDyNBXrvTEtTYgFMIbBNUVt+9mCKsU+jUUDLeFO/RXopUgR5OLiBqbY18vSHQ==",
"dev": true
},
"node_modules/@types/range-parser": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
@ -4246,6 +4275,16 @@
"integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
"dev": true
},
"node_modules/@types/systeminformation": {
"version": "3.54.1",
"resolved": "https://registry.npmjs.org/@types/systeminformation/-/systeminformation-3.54.1.tgz",
"integrity": "sha512-vvisj2mdWygyc0jk/5XtSVq9gtxCmF3nrGwv8wVway8pwNRhtPji/MU9dc1L0F6rl0F/NFIHa4ScRU7wmNaHmg==",
"deprecated": "This is a stub types definition. systeminformation provides its own type definitions, so you do not need this installed.",
"dev": true,
"dependencies": {
"systeminformation": "*"
}
},
"node_modules/@types/tapable": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.7.tgz",
@ -4277,6 +4316,12 @@
"node": ">=0.10.0"
}
},
"node_modules/@types/uuid": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz",
"integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==",
"dev": true
},
"node_modules/@types/webpack": {
"version": "4.41.29",
"resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.29.tgz",
@ -28025,6 +28070,15 @@
"integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
"dev": true
},
"@types/adm-zip": {
"version": "0.4.34",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.4.34.tgz",
"integrity": "sha512-8ToYLLAYhkRfcmmljrKi22gT2pqu7hGMDtORP1emwIEGmgUTZOsaDjzWFzW5N2frcFRz/50CWt4zA1CxJ73pmQ==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/anymatch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
@ -28222,6 +28276,15 @@
"@types/webpack": "^4"
}
},
"@types/fluent-ffmpeg": {
"version": "2.1.17",
"resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.17.tgz",
"integrity": "sha512-/bdvjKw/mtBHlJ2370d04nt4CsWqU5MrwS/NtO96V01jxitJ4+iq8OFNcqc5CegeV3TQOK3uueK02kvRK+zjUg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/glob": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
@ -28388,6 +28451,12 @@
"integrity": "sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==",
"dev": true
},
"@types/randomstring": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@types/randomstring/-/randomstring-1.1.6.tgz",
"integrity": "sha512-XRIZIMTxjcUukqQcYBdpFWGbcRDyNBXrvTEtTYgFMIbBNUVt+9mCKsU+jUUDLeFO/RXopUgR5OLiBqbY18vSHQ==",
"dev": true
},
"@types/range-parser": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
@ -28471,6 +28540,15 @@
"integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
"dev": true
},
"@types/systeminformation": {
"version": "3.54.1",
"resolved": "https://registry.npmjs.org/@types/systeminformation/-/systeminformation-3.54.1.tgz",
"integrity": "sha512-vvisj2mdWygyc0jk/5XtSVq9gtxCmF3nrGwv8wVway8pwNRhtPji/MU9dc1L0F6rl0F/NFIHa4ScRU7wmNaHmg==",
"dev": true,
"requires": {
"systeminformation": "*"
}
},
"@types/tapable": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.7.tgz",
@ -28501,6 +28579,12 @@
}
}
},
"@types/uuid": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz",
"integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==",
"dev": true
},
"@types/webpack": {
"version": "4.41.29",
"resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.29.tgz",

View File

@ -86,15 +86,20 @@
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@nuxt/types": "2.15.7",
"@types/adm-zip": "^0.4.34",
"@types/bcryptjs": "^2.4.2",
"@types/cors": "^2.8.10",
"@types/cron": "^1.7.2",
"@types/express": "^4.17.11",
"@types/express-rate-limit": "^5.1.2",
"@types/fluent-ffmpeg": "^2.1.17",
"@types/jsonwebtoken": "^8.5.1",
"@types/morgan": "^1.9.2",
"@types/node": "^14.14.41",
"@types/randomstring": "^1.1.6",
"@types/sharp": "^0.27.1",
"@types/systeminformation": "^3.54.1",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"@vue/test-utils": "^1.2.1",

View File

@ -7,9 +7,9 @@ import fstatic from 'fastify-static';
import path from 'path';
import rateLimit from 'fastify-rate-limit';
import jetpack from 'fs-jetpack';
import cron from 'cron';
// import cron from 'cron';
// @ts-ignore - nuxt types can't be found - https://github.com/nuxt/nuxt.js/issues/7651
import { loadNuxt, build } from 'nuxt';
// import { loadNuxt, build } from 'nuxt';
import Routes from './structures/routes';
@ -103,4 +103,5 @@ const start = async () => {
// new cron.CronJob('0 0 * * * *', Util.saveStatsToDb, null, true);
};
export const log = server.log;
void start();

View File

@ -0,0 +1,27 @@
import type { FastifyRequest, FastifyReply, HookHandlerDoneFunction } from 'fastify';
import prisma from '../structures/database';
export interface RequestWithUser extends FastifyRequest {
user: {
id: number;
username: string | null;
isAdmin: boolean | null;
};
}
export default async (req: RequestWithUser, res: FastifyReply, next: HookHandlerDoneFunction) => {
// TODO: Search for canApiKey in the codebase and add this file as middleware on those, before auth
const token = req.headers.token as string;
if (!token) return next();
const user = await prisma.users.findFirst({
where: {
apiKey: token
}
});
if (!user) return res.status(401).send({ message: 'Invalid authorization' });
if (!user.enabled) return res.status(401).send({ message: 'This account has been disabled' });
req.user = user;
next();
};

View File

@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["selectRelationCount"]
}
datasource db {
@ -13,7 +14,7 @@ model albums {
userId Int?
name String?
zippedAt DateTime?
createdAt DateTime?
createdAt DateTime @default(now())
editedAt DateTime?
nsfw Boolean? @default(false)
@ -22,28 +23,28 @@ model albums {
model albumsFiles {
id Int @id @default(autoincrement())
albumId Int?
fileId Int?
albumId Int
fileId Int
@@unique([albumId, fileId], name: "albumsfiles_albumid_fileid_unique")
}
model albumsLinks {
id Int @id @default(autoincrement())
albumId Int?
linkId Int? @unique
albumId Int
linkId Int @unique
}
model bans {
id Int @id @default(autoincrement())
ip String?
createdAt DateTime?
ip String
createdAt DateTime @default(now())
}
model fileTags {
id Int @id @default(autoincrement())
fileId Int?
tagId Int?
fileId Int
tagId Int
@@unique([fileId, tagId], name: "filetags_fileid_tagid_unique")
}
@ -51,26 +52,26 @@ model fileTags {
model files {
id Int @id @default(autoincrement())
userId Int?
name String?
original String?
type String?
size Int?
hash String?
ip String?
createdAt DateTime?
name String
original String
type String
size Int
hash String
ip String
createdAt DateTime @default(now())
editedAt DateTime?
}
model links {
id Int @id @default(autoincrement())
userId Int?
albumId Int?
identifier String?
views Int?
enabled Boolean?
enableDownload Boolean?
userId Int
albumId Int
identifier String
views Int @default(0)
enabled Boolean @default(true)
enableDownload Boolean @default(false)
expiresAt DateTime?
createdAt DateTime?
createdAt DateTime @default(now())
editedAt DateTime?
@@unique([userId, albumId, identifier], name: "links_userid_albumid_identifier_unique")
@ -78,8 +79,8 @@ model links {
model settings {
id Int @id @default(autoincrement())
key String?
value String?
key String
value String
}
model statistics {
@ -88,17 +89,17 @@ model statistics {
type String?
// TODO: This was JSON before so make sure to stringify and parse when saving stats
data String?
createdAt DateTime?
createdAt DateTime @default(now())
@@unique([batchId, type], name: "statistics_batchid_type_unique")
}
model tags {
id Int @id @default(autoincrement())
uuid String?
userId Int?
name String?
createdAt DateTime?
uuid String
userId Int
name String
createdAt DateTime @default(now())
editedAt DateTime?
@@unique([userId, name], name: "tags_userid_name_unique")
@ -106,14 +107,14 @@ model tags {
model users {
id Int @id @default(autoincrement())
username String?
password String?
enabled Boolean?
isAdmin Boolean?
username String
password String
enabled Boolean @default(true)
isAdmin Boolean @default(false)
apiKey String?
passwordEditedAt DateTime?
apiKeyEditedAt DateTime?
createdAt DateTime?
createdAt DateTime @default(now())
editedAt DateTime?
@@unique([username, apiKey], name: "users_username_apikey_unique")

View File

@ -1,57 +0,0 @@
const bcrypt = require('bcrypt');
const moment = require('moment');
const JWT = require('jsonwebtoken');
const Route = require('../../structures/Route');
const Util = require('../../utils/Util');
class loginPOST extends Route {
constructor() {
super('/auth/login', 'post', { bypassAuth: true });
}
async run(req, res, db) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { username, password } = req.body;
if (!username || !password) return res.status(401).json({ message: 'Invalid body provided' });
/*
Checks if the user exists
*/
const user = await db.table('users').where('username', username).first();
if (!user) return res.status(401).json({ message: 'Invalid authorization' });
/*
Checks if the user is disabled
*/
if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' });
/*
Checks if the password is right
*/
const comparePassword = await bcrypt.compare(password, user.password);
if (!comparePassword) return res.status(401).json({ message: 'Invalid authorization.' });
/*
Create the jwt with some data
*/
const jwt = JWT.sign({
iss: 'chibisafe',
sub: user.id,
iat: moment.utc().valueOf()
}, Util.config.secret, { expiresIn: '30d' });
return res.json({
message: 'Successfully logged in.',
user: {
id: user.id,
username: user.username,
apiKey: user.apiKey,
isAdmin: user.isAdmin
},
token: jwt,
apiKey: user.apiKey
});
}
}
module.exports = loginPOST;

View File

@ -1,63 +0,0 @@
const bcrypt = require('bcrypt');
const moment = require('moment');
const Route = require('../../structures/Route');
const log = require('../../utils/Log');
const Util = require('../../utils/Util');
class registerPOST extends Route {
constructor() {
super('/auth/register', 'post', { bypassAuth: true });
}
async run(req, res, db) {
// Only allow admins to create new accounts if the sign up is deactivated
const user = await Util.isAuthorized(req);
if ((!user || !user.isAdmin) && !Util.config.userAccounts) return res.status(401).json({ message: 'Creation of new accounts is currently disabled' });
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { username, password } = req.body;
if (!username || !password) return res.status(401).json({ message: 'Invalid body provided' });
if (username.length < 4 || username.length > 32) {
return res.status(400).json({ message: 'Username must have 4-32 characters' });
}
if (password.length < 6 || password.length > 64) {
return res.status(400).json({ message: 'Password must have 6-64 characters' });
}
/*
Make sure the username doesn't exist yet
*/
const exists = await db.table('users').where('username', username).first();
if (exists) return res.status(401).json({ message: 'Username already exists' });
/*
Hash the supplied password
*/
let hash;
try {
hash = await bcrypt.hash(password, 10);
} catch (error) {
log.error('Error generating password hash');
log.error(error);
return res.status(401).json({ message: 'There was a problem processing your account' });
}
/*
Create the user
*/
const now = moment.utc().toDate();
await db.table('users').insert({
username,
password: hash,
passwordEditedAt: now,
createdAt: now,
editedAt: now,
enabled: true,
isAdmin: false
}).wasMutated();
return res.json({ message: 'The account was created successfully' });
}
}
module.exports = registerPOST;

View File

@ -1,7 +1,52 @@
export interface User {
id: number;
name: string;
email: string;
username: string;
password: string;
stravaLink: string;
enabled: boolean;
isAdmin: boolean;
apiKey: string;
passwordEditedAt: string;
apiKeyEditedAt: string;
createdAt: string;
editedAt: string;
}
export interface File {
id: number;
userId?: number;
name: string;
original: string;
type: string;
size: number;
hash: string;
ip: string;
createdAt: string;
editedAt: string;
}
export interface ExtendedFile extends File {
url?: string;
thumb?: string;
thumbSquare?: string;
preview?: string;
}
export interface ExtendedFileWithData extends ExtendedFile {
data: {
hash: string;
size: number;
filename: string;
originalName: string;
mimeType: string;
};
}
export interface Album {
id: number;
userId: number;
name: string;
zippedAt: string;
createdAt: string;
editedAt: string;
nsfw: boolean;
}

View File

@ -1,36 +0,0 @@
const chalk = require('chalk');
const { dump } = require('dumper.js');
class Log {
static info(args) {
if (Log.checkIfArrayOrObject(args)) dump(args);
else console.log(args); // eslint-disable-line no-console
}
static success(args) {
if (Log.checkIfArrayOrObject(args)) dump(args);
else console.log(chalk.green(args)); // eslint-disable-line no-console
}
static warn(args) {
if (Log.checkIfArrayOrObject(args)) dump(args);
else console.log(chalk.yellow(args)); // eslint-disable-line no-console
}
static error(args) {
if (Log.checkIfArrayOrObject(args)) dump(args);
else console.log(chalk.red(args)); // eslint-disable-line no-console
}
static debug(args) {
if (Log.checkIfArrayOrObject(args)) dump(args);
else console.log(chalk.gray(args)); // eslint-disable-line no-console
}
static checkIfArrayOrObject(thing) {
if (typeof thing === typeof [] || typeof thing === typeof {}) return true;
return false;
}
}
module.exports = Log;

View File

@ -1,225 +0,0 @@
const si = require('systeminformation');
class StatsGenerator {
// symbols would be better because they're unique, but harder to serialize them
static Type = Object.freeze({
// should contain key value: number
TIME: 'time',
// should contain key value: number
BYTE: 'byte',
// should contain key value: { used: number, total: number }
BYTE_USAGE: 'byteUsage',
// should contain key data: Array<{ key: string, value: number | string }>
// and optionally a count/total
DETAILED: 'detailed',
// hidden type should be skipped during iteration, can contain anything
// these should be treated on a case by case basis on the frontend
HIDDEN: 'hidden'
});
static statGenerators = {
system: StatsGenerator.getSystemInfo,
fileSystems: StatsGenerator.getFileSystemsInfo,
uploads: StatsGenerator.getUploadsInfo,
users: StatsGenerator.getUsersInfo,
albums: StatsGenerator.getAlbumStats
};
static keyOrder = Object.keys(StatsGenerator.statGenerators);
static async getSystemInfo() {
const os = await si.osInfo();
const currentLoad = await si.currentLoad();
const mem = await si.mem();
const time = si.time();
const nodeUptime = process.uptime();
return {
'Platform': `${os.platform} ${os.arch}`,
'Distro': `${os.distro} ${os.release}`,
'Kernel': os.kernel,
'CPU Load': `${currentLoad.currentload.toFixed(1)}%`,
'CPUs Load': currentLoad.cpus.map(cpu => `${cpu.load.toFixed(1)}%`).join(', '),
'System Memory': {
value: {
used: mem.active,
total: mem.total
},
type: StatsGenerator.Type.BYTE_USAGE
},
'Memory Usage': {
value: process.memoryUsage().rss,
type: StatsGenerator.Type.BYTE
},
'System Uptime': {
value: time.uptime,
type: StatsGenerator.Type.TIME
},
'Node.js': `${process.versions.node}`,
'Service Uptime': {
value: Math.floor(nodeUptime),
type: StatsGenerator.Type.TIME
}
};
}
static async getFileSystemsInfo() {
const stats = {};
const fsSize = await si.fsSize();
for (const fs of fsSize) {
stats[`${fs.fs} (${fs.type}) on ${fs.mount}`] = {
value: {
total: fs.size,
used: fs.used
},
type: StatsGenerator.Type.BYTE_USAGE
};
}
return stats;
}
static async getUploadsInfo(db) {
const stats = {
'Total': 0,
'Images': 0,
'Videos': 0,
'Others': {
data: {},
count: 0,
type: StatsGenerator.Type.DETAILED
},
'Size in DB': {
value: 0,
type: StatsGenerator.Type.BYTE
}
};
const getFilesCountAndSize = async () => {
const uploads = await db.table('files').select('size');
return {
'Total': uploads.length,
'Size in DB': {
value: uploads.reduce((acc, upload) => acc + parseInt(upload.size, 10), 0),
type: StatsGenerator.Type.BYTE
}
};
};
const getImagesCount = async () => {
const Images = await db.table('files')
.where('type', 'like', `image/%`)
.count('id as count')
.then(rows => rows[0].count);
return { Images };
};
const getVideosCount = async () => {
const Videos = await db.table('files')
.where('type', 'like', `video/%`)
.count('id as count')
.then(rows => rows[0].count);
return { Videos };
};
const getOthersCount = async () => {
// rename to key, value from type, count
const data = await db.table('files')
.select('type as key')
.count('id as value')
.whereNot('type', 'like', `image/%`)
.whereNot('type', 'like', `video/%`)
.groupBy('key')
.orderBy('value', 'desc');
const count = data.reduce((acc, val) => acc + val.value, 0);
return {
Others: {
data,
count,
type: StatsGenerator.Type.DETAILED
}
};
};
const result = await Promise.all([getFilesCountAndSize(), getImagesCount(), getVideosCount(), getOthersCount()]);
return { ...stats, ...Object.assign({}, ...result) };
}
static async getUsersInfo(db) {
const stats = {
Total: 0,
Admins: 0,
Disabled: 0
};
const users = await db.table('users');
stats.Total = users.length;
for (const user of users) {
if (!user.enabled) {
stats.Disabled++;
}
if (user.isAdmin) {
stats.Admins++;
}
}
return stats;
}
static async getAlbumStats(db) {
const stats = {
'Total': 0,
'NSFW': 0,
'Generated archives': 0,
'Generated identifiers': 0,
'Files in albums': 0
};
const albums = await db.table('albums');
stats.Total = albums.length;
for (const album of albums) {
if (album.nsfw) stats.NSFW++;
if (album.zipGeneratedAt) stats['Generated archives']++; // XXX: Bobby checks each after if a zip really exists on the disk. Is it really needed?
}
stats['Generated identifiers'] = await db.table('albumsLinks').count('id as count').then(rows => rows[0].count);
stats['Files in albums'] = await db.table('albumsFiles')
.whereNotNull('albumId')
.count('id as count')
.then(rows => rows[0].count);
return stats;
}
static async getStats(db) {
const res = {};
for (const [name, funct] of Object.entries(StatsGenerator.statGenerators)) {
res[name] = await funct(db);
}
return res;
}
static async getMissingStats(db, existingStats) {
const res = {};
for (const [name, funct] of Object.entries(StatsGenerator.statGenerators)) {
if (existingStats.indexOf(name) === -1) res[name] = await funct(db);
}
return res;
}
}
module.exports = StatsGenerator;

View File

@ -0,0 +1,276 @@
import si from 'systeminformation';
import prisma from '../structures/database';
export const Type = Object.freeze({
// should contain key value: number
TIME: 'time',
// should contain key value: number
BYTE: 'byte',
// should contain key value: { used: number, total: number }
BYTE_USAGE: 'byteUsage',
// should contain key data: Array<{ key: string, value: number | string }>
// and optionally a count/total
DETAILED: 'detailed',
// hidden type should be skipped during iteration, can contain anything
// these should be treated on a case by case basis on the frontend
HIDDEN: 'hidden'
});
export const getSystemInfo = async () => {
const os = await si.osInfo();
const currentLoad = await si.currentLoad();
const mem = await si.mem();
const time = si.time();
const nodeUptime = process.uptime();
return {
'Platform': `${os.platform} ${os.arch}`,
'Distro': `${os.distro} ${os.release}`,
'Kernel': os.kernel,
'CPU Load': `${currentLoad.currentLoad.toFixed(1)}%`,
'CPUs Load': currentLoad.cpus.map(cpu => `${cpu.load.toFixed(1)}%`).join(', '),
'System Memory': {
value: {
used: mem.active,
total: mem.total
},
type: Type.BYTE_USAGE
},
'Memory Usage': {
value: process.memoryUsage().rss,
type: Type.BYTE
},
'System Uptime': {
value: time.uptime,
type: Type.TIME
},
'Node.js': `${process.versions.node}`,
'Service Uptime': {
value: Math.floor(nodeUptime),
type: Type.TIME
}
};
};
export const getFileSystemsInfo = async () => {
const stats = {};
const fsSize = await si.fsSize();
for (const fs of fsSize) {
stats[`${fs.fs} (${fs.type}) on ${fs.mount}`] = {
value: {
total: fs.size,
used: fs.used
},
type: Type.BYTE_USAGE
};
}
return stats;
};
export const getUploadsInfo = async () => {
const stats = {
'Total': 0,
'Images': 0,
'Videos': 0,
'Others': {
data: {},
count: 0,
type: Type.DETAILED
},
'Size in DB': {
value: 0,
type: Type.BYTE
}
};
const getFilesCountAndSize = async () => {
const uploads = await prisma.files.findMany({
select: {
size: true
}
});
return {
'Total': uploads.length,
'Size in DB': {
value: uploads.reduce((acc, upload) => acc + upload.size, 0),
type: Type.BYTE
}
};
};
const getImagesCount = async () => {
const Images = await prisma.files.count({
select: {
id: true
},
where: {
type: {
contains: 'image/'
}
}
});
return { Images: Images.id };
};
const getVideosCount = async () => {
const Videos = await prisma.files.count({
select: {
id: true
},
where: {
type: {
contains: 'video/'
}
}
});
return { Videos: Videos.id };
};
const getOthersCount = async () => {
// TODO: This needs additional testing as the returned object is no the same one as
// before the typescript rewrite due to Prisma constraints.
// https://www.prisma.io/docs/concepts/components/prisma-client/aggregation-grouping-summarizing
const data = await prisma.files.findMany({
select: {
type: true
},
where: {
NOT: [
{
type: {
contains: 'image/'
}
},
{
type: {
contains: 'video/'
}
}
]
},
orderBy: {
type: 'desc'
}
});
/*
// rename to key, value from type, count
const data = await db.table('files')
.select('type as key')
.count('id as value')
.whereNot('type', 'like', `image/%`)
.whereNot('type', 'like', `video/%`)
.groupBy('key')
.orderBy('value', 'desc');
const count = data.reduce((acc, val) => acc + val.value, 0);
return {
Others: {
data,
count,
type: Type.DETAILED
}
};
*/
};
const result = await Promise.all([getFilesCountAndSize(), getImagesCount(), getVideosCount(), getOthersCount()]);
return { ...stats, ...Object.assign({}, ...result) };
};
export const getUsersInfo = async () => {
const stats = {
Total: 0,
Admins: 0,
Disabled: 0
};
const users = await prisma.users.findMany({
select: {
enabled: true,
isAdmin: true
}
});
stats.Total = users.length;
for (const user of users) {
if (!user.enabled) {
stats.Disabled++;
}
if (user.isAdmin) {
stats.Admins++;
}
}
return stats;
};
export const getAlbumStats = async () => {
const stats = {
'Total': 0,
'NSFW': 0,
'Generated archives': 0,
'Generated identifiers': 0,
'Files in albums': 0
};
const albums = await prisma.albums.findMany({
select: {
nsfw: true,
zippedAt: true
}
});
stats.Total = albums.length;
for (const album of albums) {
if (album.nsfw) stats.NSFW++;
if (album.zippedAt) stats['Generated archives']++; // XXX: Bobby checks each after if a zip really exists on the disk. Is it really needed?
}
stats['Generated identifiers'] = await prisma.albumsLinks.count();
stats['Files in albums'] = await prisma.albumsFiles.count();
return stats;
};
// TODO: Finish this as it does nothing rn
export const getStats = () => ({});
/*
export const getStats = async () => {
const res = {};
for (const [name, funct] of Object.entries(statGenerators)) {
res[name] = await funct(db);
}
return res;
};
export const getMissingStats = async (db, existingStats) => {
const res = {};
for (const [name, funct] of Object.entries(statGenerators)) {
if (existingStats.indexOf(name) === -1) res[name] = await funct(db);
}
return res;
};
*/
export const statGenerators = {
system: getSystemInfo,
fileSystems: getFileSystemsInfo,
uploads: getUploadsInfo,
users: getUsersInfo,
albums: getAlbumStats
};
export const keyOrder = Object.keys(statGenerators);

View File

@ -1,105 +0,0 @@
const jetpack = require('fs-jetpack');
const path = require('path');
const sharp = require('sharp');
const ffmpeg = require('fluent-ffmpeg');
const previewUtil = require('./videoPreview/FragmentPreview');
const log = require('./Log');
class ThumbUtil {
static imageExtensions = ['.jpg', '.jpeg', '.gif', '.png', '.webp'];
static videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov'];
static thumbPath = path.join(__dirname, '../../../', 'uploads', 'thumbs');
static squareThumbPath = path.join(__dirname, '../../../', 'uploads', 'thumbs', 'square');
static videoPreviewPath = path.join(__dirname, '../../../', 'uploads', 'thumbs', 'preview');
static generateThumbnails(filename) {
if (!filename) return;
const ext = path.extname(filename).toLowerCase();
const output = `${filename.slice(0, -ext.length)}.webp`;
const previewOutput = `${filename.slice(0, -ext.length)}.webm`;
// eslint-disable-next-line max-len
if (ThumbUtil.imageExtensions.includes(ext)) return ThumbUtil.generateThumbnailForImage(filename, output);
// eslint-disable-next-line max-len
if (ThumbUtil.videoExtensions.includes(ext)) return ThumbUtil.generateThumbnailForVideo(filename, previewOutput);
return null;
}
static async generateThumbnailForImage(filename, output) {
const filePath = path.join(__dirname, '../../../', 'uploads', filename);
const file = await jetpack.readAsync(filePath, 'buffer');
await sharp(file)
.resize(64, 64)
.toFormat('webp')
.toFile(path.join(ThumbUtil.squareThumbPath, output));
await sharp(file)
.resize(225, null)
.toFormat('webp')
.toFile(path.join(ThumbUtil.thumbPath, output));
}
static async generateThumbnailForVideo(filename, output) {
const filePath = path.join(__dirname, '../../../', 'uploads', filename);
ffmpeg(filePath)
.thumbnail({
timestamps: [0],
filename: '%b.webp',
folder: ThumbUtil.squareThumbPath,
size: '64x64'
})
.on('error', error => log.error(error.message));
ffmpeg(filePath)
.thumbnail({
timestamps: [0],
filename: '%b.webp',
folder: ThumbUtil.thumbPath,
size: '150x?'
})
.on('error', error => log.error(error.message));
try {
await previewUtil({
input: filePath,
width: 150,
output: path.join(ThumbUtil.videoPreviewPath, output)
});
} catch (e) {
log.error(e);
}
}
static getFileThumbnail(filename) {
if (!filename) return null;
const ext = path.extname(filename).toLowerCase();
const isImage = ThumbUtil.imageExtensions.includes(ext);
const isVideo = ThumbUtil.videoExtensions.includes(ext);
if (isImage) return { thumb: `${filename.slice(0, -ext.length)}.webp` };
if (isVideo) {
return {
thumb: `${filename.slice(0, -ext.length)}.webp`,
preview: `${filename.slice(0, -ext.length)}.webm`
};
}
return null;
}
static async removeThumbs({ thumb, preview }) {
if (thumb) {
await jetpack.removeAsync(path.join(ThumbUtil.thumbPath, thumb));
await jetpack.removeAsync(path.join(ThumbUtil.squareThumbPath, thumb));
}
if (preview) {
await jetpack.removeAsync(path.join(ThumbUtil.videoPreviewPath, preview));
}
}
}
module.exports = ThumbUtil;

102
src/api/utils/ThumbUtil.ts Normal file
View File

@ -0,0 +1,102 @@
import jetpack from 'fs-jetpack';
import path from 'path';
import sharp from 'sharp';
import ffmpeg from 'fluent-ffmpeg';
import previewUtil from './videoPreview/FragmentPreview';
// TODO: Check that importing the log function works for routes and CLI (generateThumbs.ts)
import { log } from '../main';
const imageExtensions = ['.jpg', '.jpeg', '.gif', '.png', '.webp'];
const videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov'];
const thumbPath = path.join(__dirname, '../../../', 'uploads', 'thumbs');
const squareThumbPath = path.join(__dirname, '../../../', 'uploads', 'thumbs', 'square');
const videoPreviewPath = path.join(__dirname, '../../../', 'uploads', 'thumbs', 'preview');
export const generateThumbnails = (filename: string) => {
if (!filename) return;
const ext = path.extname(filename).toLowerCase();
const output = `${filename.slice(0, -ext.length)}.webp`;
const previewOutput = `${filename.slice(0, -ext.length)}.webm`;
// eslint-disable-next-line max-len
if (imageExtensions.includes(ext)) return generateThumbnailForImage(filename, output);
// eslint-disable-next-line max-len
if (videoExtensions.includes(ext)) return generateThumbnailForVideo(filename, previewOutput);
return null;
};
const generateThumbnailForImage = async (filename: string, output: string) => {
const filePath = path.join(__dirname, '../../../', 'uploads', filename);
const file = await jetpack.readAsync(filePath, 'buffer');
await sharp(file)
.resize(64, 64)
.toFormat('webp')
.toFile(path.join(squareThumbPath, output));
await sharp(file)
.resize(225, null)
.toFormat('webp')
.toFile(path.join(thumbPath, output));
};
const generateThumbnailForVideo = async (filename: string, output: string) => {
const filePath = path.join(__dirname, '../../../', 'uploads', filename);
ffmpeg(filePath)
.thumbnail({
timestamps: [0],
filename: '%b.webp',
folder: squareThumbPath,
size: '64x64'
})
.on('error', error => log.error(error.message));
ffmpeg(filePath)
.thumbnail({
timestamps: [0],
filename: '%b.webp',
folder: thumbPath,
size: '150x?'
})
.on('error', error => log.error(error.message));
try {
await previewUtil({
input: filePath,
width: 150,
output: path.join(videoPreviewPath, output)
});
} catch (e) {
log.error(e);
}
};
export const getFileThumbnail = (filename: string) => {
if (!filename) return null;
const ext = path.extname(filename).toLowerCase();
const isImage = imageExtensions.includes(ext);
const isVideo = videoExtensions.includes(ext);
if (isImage) return { thumb: `${filename.slice(0, -ext.length)}.webp` };
if (isVideo) {
return {
thumb: `${filename.slice(0, -ext.length)}.webp`,
preview: `${filename.slice(0, -ext.length)}.webm`
};
}
return null;
};
export const removeThumbs = async ({ thumb, preview }: { thumb?: string; preview?: string }) => {
if (thumb) {
await jetpack.removeAsync(path.join(thumbPath, thumb));
await jetpack.removeAsync(path.join(squareThumbPath, thumb));
}
if (preview) {
await jetpack.removeAsync(path.join(videoPreviewPath, preview));
}
};

View File

@ -1,419 +0,0 @@
/* eslint-disable no-await-in-loop */
const jetpack = require('fs-jetpack');
const randomstring = require('randomstring');
const path = require('path');
const JWT = require('jsonwebtoken');
const db = require('../structures/Database');
const moment = require('moment');
const Zip = require('adm-zip');
const uuidv4 = require('uuid/v4');
const log = require('./Log');
const ThumbUtil = require('./ThumbUtil');
const StatsGenerator = require('./StatsGenerator');
const preserveExtensions = ['.tar.gz', '.tar.z', '.tar.bz2', '.tar.lzma', '.tar.lzo', '.tar.xz'];
class Util {
static uploadPath = path.join(__dirname, '../../../', 'uploads');
static statsLastSavedTime = null;
static _config = null;
static get config() {
if (this._config) return this._config;
return (async () => {
if (this._config === null) {
const conf = await db('settings').select('key', 'value');
this._config = conf.reduce((obj, item) => (
// eslint-disable-next-line no-sequences
obj[item.key] = typeof item.value === 'string' || item.value instanceof String ? JSON.parse(item.value) : item.value, obj
), {});
}
return this._config;
})();
}
static invalidateConfigCache() {
this._config = null;
}
static getEnvironmentDefaults() {
return {
domain: process.env.DOMAIN,
routePrefix: '/api',
rateLimitWindow: process.env.RATE_LIMIT_WINDOW || 2,
rateLimitMax: process.env.RATE_LIMIT_MAX || 5,
secret: process.env.SECRET || randomstring.generate(64),
serviceName: process.env.SERVICE_NAME || 'change-me',
chunkSize: process.env.CHUNK_SIZE || 90,
maxSize: process.env.MAX_SIZE || 5000,
// eslint-disable-next-line eqeqeq
generateZips: process.env.GENERATE_ZIPS == undefined ? true : false,
generatedFilenameLength: process.env.GENERATED_FILENAME_LENGTH || 12,
generatedAlbumLength: process.env.GENERATED_ALBUM_LENGTH || 6,
blockedExtensions: process.env.BLOCKED_EXTENSIONS ? process.env.BLOCKED_EXTENSIONS.split(',') : ['.jar', '.exe', '.msi', '.com', '.bat', '.cmd', '.scr', '.ps1', '.sh'],
// eslint-disable-next-line eqeqeq
publicMode: process.env.PUBLIC_MODE == undefined ? true : false,
// eslint-disable-next-line eqeqeq
userAccounts: process.env.USER_ACCOUNTS == undefined ? true : false,
metaThemeColor: process.env.META_THEME_COLOR || '#20222b',
metaDescription: process.env.META_DESCRIPTION || 'Blazing fast file uploader and bunker written in node! 🚀',
metaKeywords: process.env.META_KEYWORDS || 'chibisafe,lolisafe,upload,uploader,file,vue,images,ssr,file uploader,free',
metaTwitterHandle: process.env.META_TWITTER_HANDLE || '@your-handle',
backgroundImageURL: process.env.BACKGROUND_IMAGE_URL || '',
logoURL: process.env.LOGO_URL || '',
statisticsCron: process.env.STATISTICS_CRON || '0 0 * * * *',
enabledStatistics: process.env.ENABLED_STATISTICS ? process.env.ENABLED_STATISTICS.split(',') : ['system', 'fileSystems', 'uploads', 'users', 'albums'],
savedStatistics: process.env.SAVED_STATISTICS ? process.env.SAVED_STATISTICS.split(',') : ['system', 'fileSystems', 'uploads', 'users', 'albums']
};
}
static async wipeConfigDb() {
try {
await db.table('settings').del();
} catch (error) {
console.error(error);
}
}
static async writeConfigToDb(config, wipe = false) {
// TODO: Check that the config passes the joi schema validation
if (!config || !config.key) return;
try {
config.value = JSON.stringify(config.value);
await db.table('settings').insert(config);
} catch (error) {
console.error(error);
} finally {
this.invalidateConfigCache();
}
}
static uuid() {
return uuidv4();
}
static isExtensionBlocked(extension) {
return this.config.blockedExtensions.includes(extension);
}
static getMimeFromType(fileTypeMimeObj) {
return fileTypeMimeObj ? fileTypeMimeObj.mime : undefined;
}
static constructFilePublicLink(req, file) {
/*
TODO: This wont work without a reverse proxy serving both
the site and the API under the same domain. Pls fix.
*/
const host = this.getHost(req);
file.url = `${host}/${file.name}`;
const { thumb, preview } = ThumbUtil.getFileThumbnail(file.name) || {};
if (thumb) {
file.thumb = `${host}/thumbs/${thumb}`;
file.thumbSquare = `${host}/thumbs/square/${thumb}`;
file.preview = preview && `${host}/thumbs/preview/${preview}`;
}
return file;
}
static getUniqueFilename(extension) {
const retry = (i = 0) => {
const filename = randomstring.generate({
length: this.config.generatedFilenameLength,
capitalization: 'lowercase'
}) + extension;
// TODO: Change this to look for the file in the db instead of in the filesystem
const exists = jetpack.exists(path.join(Util.uploadPath, filename));
if (!exists) return filename;
if (i < 5) return retry(i + 1);
log.error('Couldnt allocate identifier for file');
return null;
};
return retry();
}
static getUniqueAlbumIdentifier() {
const retry = async (i = 0) => {
const identifier = randomstring.generate({
length: this.config.generatedAlbumLength,
capitalization: 'lowercase'
});
const exists = await db
.table('links')
.where({ identifier })
.first();
if (!exists) return identifier;
/*
It's funny but if you do i++ the asignment never gets done resulting in an infinite loop
*/
if (i < 5) return retry(i + 1);
log.error('Couldnt allocate identifier for album');
return null;
};
return retry();
}
static getFilenameFromPath(fullPath) {
return fullPath.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape
}
static async deleteFile(filename, deleteFromDB = false) {
const thumbName = ThumbUtil.getFileThumbnail(filename);
try {
await jetpack.removeAsync(path.join(Util.uploadPath, filename));
if (thumbName) await ThumbUtil.removeThumbs(thumbName);
if (deleteFromDB) {
await db
.table('files')
.where('name', filename)
.delete()
.wasMutated();
}
} catch (error) {
log.error(`There was an error removing the file < ${filename} >`);
log.error(error);
}
}
static async deleteAllFilesFromAlbum(id) {
try {
const fileAlbums = await db.table('albumsFiles').where({ albumId: id });
for (const fileAlbum of fileAlbums) {
const file = await db
.table('files')
.where({ id: fileAlbum.fileId })
.first();
if (!file) continue;
await this.deleteFile(file.name, true);
}
} catch (error) {
log.error(error);
}
}
static async deleteAllFilesFromUser(id) {
try {
const files = await db.table('files').where({ userId: id });
for (const file of files) {
await this.deleteFile(file.name, true);
}
} catch (error) {
log.error(error);
}
}
static async deleteAllFilesFromTag(id) {
try {
const fileTags = await db.table('fileTags').where({ tagId: id });
for (const fileTag of fileTags) {
const file = await db
.table('files')
.where({ id: fileTag.fileId })
.first();
if (!file) continue;
await this.deleteFile(file.name, true);
}
} catch (error) {
log.error(error);
}
}
static async isAuthorized(req) {
if (req.headers.token) {
const user = await db.table('users').where({ apiKey: req.headers.token }).first();
if (!user || !user.enabled) return false;
return user;
}
if (!req.headers.authorization) return false;
const token = req.headers.authorization.split(' ')[1];
if (!token) return false;
return JWT.verify(token, this.config.secret, async (error, decoded) => {
if (error) {
log.error(error);
return false;
}
const id = decoded ? decoded.sub : '';
const iat = decoded ? decoded.iat : '';
const user = await db
.table('users')
.where({ id })
.first();
if (!user || !user.enabled) return false;
if (iat && iat < moment(user.passwordEditedAt).format('x')) return false;
return user;
});
}
static createZip(files, album) {
try {
const zip = new Zip();
for (const file of files) {
zip.addLocalFile(path.join(Util.uploadPath, file));
}
zip.writeZip(
path.join(__dirname, '../../../', 'uploads', 'zips', `${album.userId}-${album.id}.zip`)
);
} catch (error) {
log.error(error);
}
}
static generateThumbnails = ThumbUtil.generateThumbnails;
static async fileExists(req, res, exists, filename) {
exists = Util.constructFilePublicLink(req, exists);
res.json({
message: 'Successfully uploaded the file.',
name: exists.name,
hash: exists.hash,
size: exists.size,
url: exists.url,
thumb: exists.thumb,
deleteUrl: `${this.getHost(req)}/api/file/${exists.id}`,
repeated: true
});
return this.deleteFile(filename);
}
static async storeFileToDb(req, res, user, file, db) {
const dbFile = await db.table('files')
// eslint-disable-next-line func-names
.where(function() {
if (user === undefined) {
this.whereNull('userId');
} else {
this.where('userId', user.id);
}
})
.where({
hash: file.data.hash,
size: file.data.size
})
.first();
if (dbFile) {
await this.fileExists(req, res, dbFile, file.data.filename);
return;
}
const now = moment.utc().toDate();
const data = {
userId: user ? user.id : null,
name: file.data.filename,
original: file.data.originalname,
type: file.data.mimetype,
size: file.data.size,
hash: file.data.hash,
ip: req.ip,
createdAt: now,
editedAt: now
};
Util.generateThumbnails(file.data.filename);
let fileId;
if (process.env.DB_CLIENT === 'sqlite3') {
fileId = await db.table('files').insert(data).wasMutated();
} else {
fileId = await db.table('files').insert(data, 'id').wasMutated();
}
return {
file: data,
id: fileId
};
}
static async saveFileToAlbum(db, albumId, insertedId) {
if (!albumId) return;
const now = moment.utc().toDate();
try {
await db.table('albumsFiles').insert({ albumId, fileId: insertedId[0] }).wasMutated();
await db.table('albums').where('id', albumId).update('editedAt', now);
} catch (error) {
console.error(error);
}
}
static getExtension(filename) {
// Always return blank string if the filename does not seem to have a valid extension
// Files such as .DS_Store (anything that starts with a dot, without any extension after) will still be accepted
if (!/\../.test(filename)) return '';
let lower = filename.toLowerCase(); // due to this, the returned extname will always be lower case
let multi = '';
let extname = '';
// check for multi-archive extensions (.001, .002, and so on)
if (/\.\d{3}$/.test(lower)) {
multi = lower.slice(lower.lastIndexOf('.') - lower.length);
lower = lower.slice(0, lower.lastIndexOf('.'));
}
// check against extensions that must be preserved
for (const extPreserve of preserveExtensions) {
if (lower.endsWith(extPreserve)) {
extname = extPreserve;
break;
}
}
if (!extname) {
extname = lower.slice(lower.lastIndexOf('.') - lower.length); // path.extname(lower)
}
return extname + multi;
}
// TODO: Allow choosing what to save to db and what stats we care about in general
// TODO: if a stat is not saved to db but selected to be shows on the dashboard, it will be generated during the request
static async saveStatsToDb(force) {
// If there were no changes since the instance started, don't generate new stats
// OR
// if we alredy saved a stats to the db, and there were no new changes to the db since then
// skip generating and saving new stats.
if (!force &&
(!db.userParams.lastMutationTime ||
(Util.statsLastSavedTime && Util.statsLastSavedTime > db.userParams.lastMutationTime)
)
) {
return;
}
const now = moment.utc().toDate();
const stats = await StatsGenerator.getStats(db);
let batchId = 1;
const res = (await db('statistics').max({ lastBatch: 'batchId' }))[0];
if (res && res.lastBatch) {
batchId = res.lastBatch + 1;
}
try {
for (const [type, data] of Object.entries(stats)) {
await db.table('statistics').insert({ type, data: JSON.stringify(data), createdAt: now, batchId });
}
Util.statsLastSavedTime = now.getTime();
} catch (error) {
console.error(error);
}
}
static getHost(req) {
return `${req.protocol}://${req.headers.host}`;
}
}
module.exports = Util;

392
src/api/utils/Util.ts Normal file
View File

@ -0,0 +1,392 @@
import jetpack from 'fs-jetpack';
import randomstring from 'randomstring';
import path from 'path';
import prisma from '../structures/database';
import moment from 'moment';
import Zip from 'adm-zip';
import { generateThumbnails, getFileThumbnail, removeThumbs } from './ThumbUtil';
import { getStats } from './StatsGenerator';
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { File, ExtendedFile, ExtendedFileWithData, Album, User } from '../structures/interfaces';
// TODO: Check that importing the log function works for routes and CLI (generateThumbs.ts)
import { log } from '../main';
export { generateThumbnails } from './ThumbUtil';
export { v4 as uuid } from 'uuid';
const preserveExtensions = ['.tar.gz', '.tar.z', '.tar.bz2', '.tar.lzma', '.tar.lzo', '.tar.xz'];
export const uploadPath = path.join(__dirname, '../../../', 'uploads');
export const statsLastSavedTime = null;
export const _config = null;
export const getConfig = async () => {
const config = await prisma.settings.findMany();
return config.reduce((conf, item) => {
if (typeof item.value === 'string') {
conf[item.key] = JSON.parse(item.value);
} else {
conf[item.key] = item.value;
}
return config;
}, {} as Record<string, any>);
};
export const getEnvironmentDefaults = () => ({
domain: process.env.DOMAIN,
routePrefix: '/api',
rateLimitWindow: process.env.RATE_LIMIT_WINDOW ?? 2,
rateLimitMax: process.env.RATE_LIMIT_MAX ?? 5,
secret: process.env.SECRET ?? randomstring.generate(64),
serviceName: process.env.SERVICE_NAME ?? 'change-me',
chunkSize: process.env.CHUNK_SIZE ?? 90,
maxSize: process.env.MAX_SIZE ?? 5000,
// eslint-disable-next-line eqeqeq
generateZips: process.env.GENERATE_ZIPS == undefined ? true : false,
generatedFilenameLength: process.env.GENERATED_FILENAME_LENGTH ?? 12,
generatedAlbumLength: process.env.GENERATED_ALBUM_LENGTH ?? 6,
blockedExtensions: process.env.BLOCKED_EXTENSIONS ? process.env.BLOCKED_EXTENSIONS.split(',') : ['.jar', '.exe', '.msi', '.com', '.bat', '.cmd', '.scr', '.ps1', '.sh'],
// eslint-disable-next-line eqeqeq
publicMode: process.env.PUBLIC_MODE == undefined ? true : false,
// eslint-disable-next-line eqeqeq
userAccounts: process.env.USER_ACCOUNTS == undefined ? true : false,
metaThemeColor: process.env.META_THEME_COLOR ?? '#20222b',
metaDescription: process.env.META_DESCRIPTION ?? 'Blazing fast file uploader and bunker written in node! 🚀',
metaKeywords: process.env.META_KEYWORDS ?? 'chibisafe,lolisafe,upload,uploader,file,vue,images,ssr,file uploader,free',
metaTwitterHandle: process.env.META_TWITTER_HANDLE ?? '@your-handle',
backgroundImageURL: process.env.BACKGROUND_IMAGE_URL ?? '',
logoURL: process.env.LOGO_URL ?? '',
statisticsCron: process.env.STATISTICS_CRON ?? '0 0 * * * *',
enabledStatistics: process.env.ENABLED_STATISTICS ? process.env.ENABLED_STATISTICS.split(',') : ['system', 'fileSystems', 'uploads', 'users', 'albums'],
savedStatistics: process.env.SAVED_STATISTICS ? process.env.SAVED_STATISTICS.split(',') : ['system', 'fileSystems', 'uploads', 'users', 'albums']
});
export const wipeConfigDb = async () => {
try {
await prisma.settings.deleteMany();
} catch (error) {
console.error(error);
}
};
export const writeConfigToDb = async (config: { key: string; value: string }) => {
if (!config.key) return;
try {
config.value = JSON.stringify(config.value);
await prisma.settings.create({
data: config
});
} catch (error) {
console.error(error);
}
};
export const isExtensionBlocked = async (extension: string) => (await getConfig()).blockedExtensions.includes(extension);
export const getMimeFromType = (fileTypeMimeObj: Record<string, null>) => fileTypeMimeObj.mime;
export const constructFilePublicLink = (req: FastifyRequest, file: File) => {
/*
TODO: This wont work without a reverse proxy serving both
the site and the API under the same domain. Pls fix.
*/
const extended = file as ExtendedFile;
const host = getHost(req);
extended.url = `${host}/${extended.name}`;
const { thumb, preview } = getFileThumbnail(extended.name) ?? {};
if (thumb) {
extended.thumb = `${host}/thumbs/${thumb}`;
extended.thumbSquare = `${host}/thumbs/square/${thumb}`;
extended.preview = preview && `${host}/thumbs/preview/${preview}`;
}
return extended;
};
export const getUniqueFilename = (extension: string) => {
const retry: any = async (i = 0) => {
const filename = randomstring.generate({
length: (await getConfig()).generatedFilenameLength,
capitalization: 'lowercase'
}) + extension;
// TODO: Change this to look for the file in the db instead of in the filesystem
const exists = jetpack.exists(path.join(uploadPath, filename));
if (!exists) return filename;
if (i < 5) return retry(i + 1);
log.error('Couldnt allocate identifier for file');
return null;
};
return retry();
};
export const getUniqueAlbumIdentifier = () => {
const retry: any = async (i = 0) => {
const identifier = randomstring.generate({
length: (await getConfig()).generatedAlbumLength,
capitalization: 'lowercase'
});
const exists = await prisma.links.findFirst({
where: {
identifier
}
});
if (!exists) return identifier;
/*
It's funny but if you do i++ the asignment never gets done resulting in an infinite loop
*/
if (i < 5) return retry(i + 1);
log.error('Couldnt allocate identifier for album');
return null;
};
return retry();
};
export const getFilenameFromPath = (fullPath: string) => fullPath.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape
export const deleteFile = async (filename: string, deleteFromDB = false) => {
const thumbName = getFileThumbnail(filename);
try {
await jetpack.removeAsync(path.join(uploadPath, filename));
if (thumbName) await removeThumbs(thumbName);
if (deleteFromDB) {
await prisma.files.deleteMany({
where: {
name: filename
}
});
}
} catch (error) {
log.error(`There was an error removing the file < ${filename} >`);
log.error(error);
}
};
export const deleteAllFilesFromAlbum = async (id: number) => {
try {
const fileAlbums = await prisma.albumsFiles.findMany({
where: {
albumId: id
},
select: {
fileId: true
}
});
for (const fileAlbum of fileAlbums) {
const file = await prisma.files.findUnique({
where: {
id: fileAlbum.fileId
}
});
if (!file?.name) continue;
await deleteFile(file.name, true);
}
} catch (error) {
log.error(error);
}
};
export const deleteAllFilesFromUser = async (id: number) => {
try {
const files = await prisma.files.findMany({
where: {
userId: id
}
});
for (const file of files) {
await deleteFile(file.name, true);
}
} catch (error) {
log.error(error);
}
};
export const deleteAllFilesFromTag = async (id: number) => {
try {
const fileTags = await prisma.fileTags.findMany({
where: {
tagId: id
}
});
for (const fileTag of fileTags) {
const file = await prisma.files.findFirst({
where: {
id: fileTag.fileId
},
select: {
name: true
}
});
if (!file) continue;
await deleteFile(file.name, true);
}
} catch (error) {
log.error(error);
}
};
export const createZip = (files: string[], album: Album) => {
try {
const zip = new Zip();
for (const file of files) {
zip.addLocalFile(path.join(uploadPath, file));
}
zip.writeZip(
path.join(__dirname, '../../../', 'uploads', 'zips', `${album.userId}-${album.id}.zip`)
);
} catch (error) {
log.error(error);
}
};
export const fileExists = (req: FastifyRequest, res: FastifyReply, exists: File, filename: string) => {
const file = constructFilePublicLink(req, exists);
void res.send({
message: 'Successfully uploaded the file.',
name: file.name,
hash: file.hash,
size: file.size,
url: file.url,
thumb: file.thumb,
deleteUrl: `${getHost(req)}/api/file/${file.id}`,
repeated: true
});
return deleteFile(filename);
};
export const storeFileToDb = async (req: FastifyRequest, res: FastifyReply, user: User, file: ExtendedFileWithData) => {
const dbFile = await prisma.files.findFirst({
where: {
hash: file.data.hash,
size: file.data.size,
userId: user.id ? user.id : undefined
}
});
if (dbFile) {
await fileExists(req, res, dbFile, file.data.filename);
return;
}
const now = moment.utc().toDate();
const data = {
userId: user.id ? user.id : undefined,
name: file.data.filename,
original: file.data.originalName,
type: file.data.mimeType,
size: file.data.size,
hash: file.data.hash,
ip: req.ip,
createdAt: now,
editedAt: now
};
void generateThumbnails(file.data.filename);
const fileId = await prisma.files.create({
data
});
return {
file: data,
id: fileId.id
};
};
export const saveFileToAlbum = async (albumId: number, insertedId: number) => {
if (!albumId) return;
const now = moment.utc().toDate();
try {
await prisma.albumsFiles.create({
data: {
albumId,
fileId: insertedId
}
});
await prisma.albums.update({
where: {
id: albumId
},
data: {
editedAt: now
}
});
} catch (error) {
console.error(error);
}
};
export const getExtension = (filename: string) => {
// Always return blank string if the filename does not seem to have a valid extension
// Files such as .DS_Store (anything that starts with a dot, without any extension after) will still be accepted
if (!/\../.test(filename)) return '';
let lower = filename.toLowerCase(); // due to this, the returned extname will always be lower case
let multi = '';
let extname = '';
// check for multi-archive extensions (.001, .002, and so on)
if (/\.\d{3}$/.test(lower)) {
multi = lower.slice(lower.lastIndexOf('.') - lower.length);
lower = lower.slice(0, lower.lastIndexOf('.'));
}
// check against extensions that must be preserved
for (const extPreserve of preserveExtensions) {
if (lower.endsWith(extPreserve)) {
extname = extPreserve;
break;
}
}
if (!extname) {
extname = lower.slice(lower.lastIndexOf('.') - lower.length); // path.extname(lower)
}
return extname + multi;
};
/*
// TODO: Allow choosing what to save to db and what stats we care about in general
// TODO: if a stat is not saved to db but selected to be shows on the dashboard, it will be generated during the request
export const saveStatsToDb = async (force: boolean) => {
// If there were no changes since the instance started, don't generate new stats
// OR
// if we alredy saved a stats to the db, and there were no new changes to the db since then
// skip generating and saving new stats.
if (!force &&
(!db.userParams.lastMutationTime ||
(Util.statsLastSavedTime && Util.statsLastSavedTime > db.userParams.lastMutationTime)
)
) {
return;
}
const now = moment.utc().toDate();
const stats = await getStats(db);
let batchId = 1;
const res = (await db('statistics').max({ lastBatch: 'batchId' }))[0];
if (res && res.lastBatch) {
batchId = res.lastBatch + 1;
}
try {
for (const [type, data] of Object.entries(stats)) {
await db.table('statistics').insert({ type, data: JSON.stringify(data), createdAt: now, batchId });
}
Util.statsLastSavedTime = now.getTime();
} catch (error) {
console.error(error);
}
};
*/
export const getHost = (req: FastifyRequest) => `${req.protocol}://${req.headers.host as string}`;

View File

@ -1,17 +1,17 @@
require('dotenv').config();
import dotenv from 'dotenv';
dotenv.config();
const fs = require('fs');
const path = require('path');
const ThumbUtil = require('./ThumbUtil');
import fs from 'fs';
import path from 'path';
import { generateThumbnails } from './ThumbUtil';
const start = async () => {
const files = fs.readdirSync(path.join(__dirname, '../../../uploads'));
for (const fileName of files) {
console.log(`Generating thumb for '${fileName}`);
// eslint-disable-next-line no-await-in-loop
await ThumbUtil.generateThumbnails(fileName);
await generateThumbnails(fileName);
}
};
start();
void start();

View File

@ -1,18 +1,20 @@
require('dotenv').config();
const blake3 = require('blake3');
const path = require('path');
const fs = require('fs');
const db = require('knex')({
client: 'sqlite3',
connection: {
filename: path.join(__dirname, '../../../database/', 'database.sqlite')
}
});
import dotenv from 'dotenv';
dotenv.config();
import blake3 from 'blake3';
import path from 'path';
import fs from 'fs';
import prisma from '../structures/database';
const start = async () => {
const hrstart = process.hrtime();
const uploads = await db.table('files')
.select('id', 'name', 'hash');
const uploads = await prisma.files.findMany({
select: {
id: true,
name: true,
hash: true
}
});
console.log(`Uploads : ${uploads.length}`);
@ -26,21 +28,30 @@ const start = async () => {
for (const upload of uploads) {
await new Promise((resolve, reject) => {
// TODO: Check that this return works as a continue and doesnt break the loop
if (!upload.name) return;
fs.createReadStream(path.join(__dirname, '../../../uploads', upload.name))
.on('error', reject)
.pipe(blake3.createHash())
.on('error', reject)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.on('data', async source => {
const hash = source.toString('hex');
console.log(`${upload.name}: ${hash}`);
await db.table('files')
.update('hash', hash)
.where('id', upload.id);
const hash = source.toStrin('hex');
console.log(`${upload.name as string}: ${hash as string}`);
await prisma.files.update({
where: {
id: upload.id
},
data: {
hash
}
});
done++;
resolve();
// TODO: Removed parenthesis here, check if it still works as expected
resolve;
});
}).catch(error => {
console.log(`${upload.name}: ${error.toString()}`);
console.log(`${upload.name as string}: ${error.toString() as string}`);
});
}
@ -52,4 +63,4 @@ const start = async () => {
process.exit(0);
};
start();
void start();

View File

@ -1,10 +1,11 @@
/* eslint-disable no-bitwise */
const ffmpeg = require('fluent-ffmpeg');
const probe = require('ffmpeg-probe');
import ffmpeg from 'fluent-ffmpeg';
// @ts-ignore - no typings for this package
import probe from 'ffmpeg-probe';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
const getRandomInt = (min, max) => {
const getRandomInt = (min: number, max: number) => {
const minInt = Math.ceil(min);
const maxInt = Math.floor(max);
@ -12,7 +13,7 @@ const getRandomInt = (min, max) => {
return Math.floor(Math.random() * (maxInt - minInt + 1) + minInt);
};
const getStartTime = (vDuration, fDuration, ignoreBeforePercent, ignoreAfterPercent) => {
const getStartTime = (vDuration: number, fDuration: number, ignoreBeforePercent: number, ignoreAfterPercent: number) => {
// by subtracting the fragment duration we can be sure that the resulting
// start time + fragment duration will be less than the video duration
const safeVDuration = vDuration - fDuration;
@ -25,7 +26,7 @@ const getStartTime = (vDuration, fDuration, ignoreBeforePercent, ignoreAfterPerc
return getRandomInt(ignoreBeforePercent * safeVDuration, ignoreAfterPercent * safeVDuration);
};
module.exports = async opts => {
export default async (opts: any) => {
const {
log = noop,
@ -48,7 +49,7 @@ module.exports = async opts => {
const startTime = getStartTime(duration, fragmentDurationSecond, ignoreBeforePercent, ignoreAfterPercent);
const result = { startTime, duration };
const result: any = { startTime, duration };
await new Promise((resolve, reject) => {
let scale = null;
@ -56,15 +57,15 @@ module.exports = async opts => {
if (width && height) {
result.width = width | 0;
result.height = height | 0;
scale = `scale=${width}:${height}`;
scale = `scale=${width as number}:${height as number}`;
} else if (width) {
result.width = width | 0;
result.height = ((info.height * width) / info.width) | 0;
scale = `scale=${width}:-1`;
scale = `scale=${width as number}:-1`;
} else if (height) {
result.height = height | 0;
result.width = ((info.width * height) / info.height) | 0;
scale = `scale=-1:${height}`;
scale = `scale=-1:${height as number}`;
} else {
result.width = info.width;
result.height = info.height;
@ -75,10 +76,10 @@ module.exports = async opts => {
.inputOptions([`-ss ${startTime}`])
.outputOptions(['-vsync', 'vfr'])
.outputOptions(['-q:v', quality, '-vf', scale])
.outputOptions([`-t ${fragmentDurationSecond}`])
.outputOptions([`-t ${fragmentDurationSecond as number}`])
.noAudio()
.output(output)
.on('start', cmd => log && log({ cmd }))
.on('start', cmd => log?.({ cmd }))
.on('end', resolve)
.on('error', reject)
.run();

View File

@ -1,10 +1,12 @@
/* eslint-disable no-bitwise */
const ffmpeg = require('fluent-ffmpeg');
const probe = require('ffmpeg-probe');
import ffmpeg from 'fluent-ffmpeg';
// @ts-ignore - no typings for this package
import probe from 'ffmpeg-probe';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
module.exports = async opts => {
module.exports = async (opts: any) => {
const {
log = noop,
@ -22,7 +24,7 @@ module.exports = async opts => {
const info = await probe(input);
// const numFramesTotal = parseInt(info.streams[0].nb_frames, 10);
const { avg_frame_rate: avgFrameRate, duration } = info.streams[0];
const [frames, time] = avgFrameRate.split('/').map(e => parseInt(e, 10));
const [frames, time] = avgFrameRate.split('/').map((e: string) => parseInt(e, 10));
const numFramesTotal = (frames / time) * duration;
@ -30,26 +32,26 @@ module.exports = async opts => {
numFramesToCapture = Math.max(1, Math.min(numFramesTotal, numFramesToCapture)) | 0;
const nthFrame = (numFramesTotal / numFramesToCapture) | 0;
const result = {
const result: any = {
output,
numFrames: numFramesToCapture
};
await new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
let scale = null;
if (width && height) {
result.width = width | 0;
result.height = height | 0;
scale = `scale=${width}:${height}`;
scale = `scale=${width as number}:${height as number}`;
} else if (width) {
result.width = width | 0;
result.height = ((info.height * width) / info.width) | 0;
scale = `scale=${width}:-1`;
scale = `scale=${width as number}:-1`;
} else if (height) {
result.height = height | 0;
result.width = ((info.width * height) / info.height) | 0;
scale = `scale=-1:${height}`;
scale = `scale=-1:${height as number}`;
} else {
result.width = info.width;
result.height = info.height;
@ -63,7 +65,7 @@ module.exports = async opts => {
.noAudio()
.outputFormat('webm')
.output(output)
.on('start', cmd => log && log({ cmd }))
.on('start', cmd => log?.({ cmd }))
.on('end', () => resolve())
.on('error', err => reject(err))
.run();