Compare commits

...

15 Commits

Author SHA1 Message Date
Pitu beb529e483 feat: wrote a few more routes 2021-08-24 01:05:36 +09:00
Pitu b0ab951a98 feat: implement album links endpoints 2021-08-18 01:12:50 +09:00
Pitu 858f0ae4b0 chore: update prisma 2021-08-18 01:12:34 +09:00
Pitu 9321750461 wip 2021-06-25 01:28:59 +09:00
Pitu 7114830582 wip 2021-06-24 19:13:48 +09:00
Pitu b924fbd314 wip 2021-06-24 02:07:07 +09:00
Pitu af0e46e552 feat: typescript and fastify 2021-06-21 20:01:50 +09:00
Pitu 77db6f34c6 Merge branch 'master' into feature/typescript 2021-06-21 15:23:28 +09:00
Pitu 8dd9b20d20 wip 2021-06-20 11:51:05 +09:00
Pitu a634cefb0c wip 2021-06-20 11:51:01 +09:00
Pitu bc5ca732b0 wip 2021-06-20 03:53:38 +09:00
Pitu ff80ea8431 Merge branch 'master' into feature/typescript 2021-06-19 02:08:28 +09:00
Pitu 15674358f1 wip 2021-06-19 02:01:25 +09:00
Pitu 01e85b5e2f wip 2021-06-18 02:49:12 +09:00
Pitu c8f19898ae wip 2021-06-18 01:38:51 +09:00
83 changed files with 35593 additions and 9354 deletions

2
.gitattributes vendored
View File

@ -1 +1 @@
* text=auto eol=lf
* text=auto eol=lf

1
.npmrc
View File

@ -1,4 +1,3 @@
audit=false
fund=false
node-version=false
legacy-peer-deps=true

Binary file not shown.

View File

@ -1,23 +0,0 @@
require('dotenv').config();
module.exports = {
client: process.env.DB_CLIENT,
connection: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
filename: 'database/database.sqlite'
},
pool: {
min: process.env.DATABASE_POOL_MIN || 2,
max: process.env.DATABASE_POOL_MAX || 10
},
migrations: {
directory: 'src/api/database/migrations'
},
seeds: {
directory: 'src/api/database/seeds'
},
useNullAsDefault: process.env.DB_CLIENT === 'sqlite3'
};

40725
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "chibisafe",
"version": "4.0.2",
"version": "5.0.0",
"description": "Blazing fast file uploader and bunker written in node! 🚀",
"license": "MIT",
"author": {
@ -9,9 +9,10 @@
"url": "https://github.com/Pitu"
},
"scripts": {
"setup": "node src/setup.js && npm run migrate && npm run seed",
"start": "npm run migrate && nuxt build && cross-env NODE_ENV=production node src/api/structures/Server",
"dev": "nodemon src/api/structures/Server",
"setup": "node src/api/scripts/setup.ts && npm run migrate && npm run seed",
"build": "tsc --noEmit",
"start": "npm run migrate && nuxt build && cross-env NODE_ENV=production node src/api/main.ts",
"dev": "nodemon src/api/main.ts",
"migrate": "knex migrate:latest",
"seed": "knex seed:run",
"restart": "pm2 restart chibisafe",
@ -30,57 +31,53 @@
"url": "https://github.com/WeebDev/chibisafe/issues"
},
"engines": {
"node": ">=12.0.0"
"node": ">=16.0.0"
},
"dependencies": {
"@mdi/font": "^5.8.55",
"@nuxtjs/axios": "^5.12.5",
"adm-zip": "^0.4.13",
"bcrypt": "^5.0.1",
"@prisma/client": "^2.29.1",
"adm-zip": "^0.5.5",
"axios": "^0.21.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^7.1.5",
"blake3": "^2.1.4",
"body-parser": "^1.18.3",
"buefy": "^0.9.4",
"busboy": "^0.2.14",
"chalk": "^2.4.1",
"chrono-node": "^2.1.4",
"compression": "^1.7.2",
"busboy": "^0.3.1",
"chrono-node": "^2.3.0",
"cookie-universal-nuxt": "^2.0.14",
"cors": "^2.8.5",
"cron": "^1.8.2",
"dotenv": "^6.2.0",
"dumper.js": "^1.3.1",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-rate-limit": "^3.4.0",
"fastify": "^3.18.0",
"fastify-cors": "^6.0.1",
"fastify-formbody": "^5.0.0",
"fastify-helmet": "^5.3.1",
"fastify-nuxtjs": "^1.0.1",
"fastify-rate-limit": "^5.5.0",
"fastify-static": "^4.2.2",
"ffmpeg-probe": "^1.0.6",
"file-saver": "^2.0.1",
"file-type": "^16.1.0",
"file-type": "^16.5.0",
"fluent-ffmpeg": "^2.1.2",
"fs-jetpack": "^2.2.2",
"helmet": "^3.15.1",
"fs-jetpack": "^4.1.0",
"imagesloaded": "^4.1.4",
"joi": "^17.3.0",
"jsonwebtoken": "^8.5.0",
"knex": "^0.21.15",
"jsonwebtoken": "^8.5.1",
"masonry-layout": "^4.2.2",
"moment": "^2.24.0",
"morgan": "^1.10.0",
"multer": "^1.4.1",
"mysql": "^2.16.0",
"moment": "^2.29.1",
"nuxt": "^2.14.12",
"nuxt-dropzone": "^0.2.8",
"pg": "^7.8.1",
"pino-pretty": "^5.0.2",
"qoa": "^0.2.0",
"randomstring": "^1.1.5",
"rotating-file-stream": "^2.1.3",
"search-query-parser": "^1.5.5",
"serve-static": "^1.13.2",
"sharp": "^0.27.0",
"sqlite3": "^5.0.0",
"systeminformation": "^4.34.5",
"uuid": "^3.3.2",
"randomstring": "^1.2.1",
"search-query-parser": "^1.6.0",
"sharp": "^0.28.3",
"systeminformation": "^5.7.7",
"uuid": "^8.3.2",
"v-clipboard": "^2.2.1",
"vue-axios": "^2.1.4",
"vue-isyourpasswordsafe": "^1.0.2",
"vue-isyourpasswordsafe": "^2.0.0",
"vue-plyr": "^5.1.0",
"vue-timeago": "^3.4.4",
"vue2-transitions": "^0.2.3",
@ -89,48 +86,61 @@
"devDependencies": {
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@vue/test-utils": "^1.1.2",
"@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",
"autoprefixer": "^9.4.7",
"axios": "^0.21.1",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^26.6.3",
"cross-env": "^5.2.0",
"eslint": "^7.17.0",
"eslint-config-aqua": "^7.3.0",
"babel-jest": "^27.0.2",
"cpy-cli": "^3.1.1",
"cross-env": "^7.0.3",
"eslint": "^7.24.0",
"eslint-config-marine": "^9.0.6",
"eslint-import-resolver-nuxt": "^1.0.1",
"eslint-plugin-vue": "^5.2.1",
"jest": "^26.6.3",
"eslint-plugin-vue": "^7.11.1",
"jest": "^27.0.4",
"jest-serializer-vue": "^2.0.2",
"node-sass": "^5.0.0",
"nodemon": "^1.19.4",
"node-sass": "^6.0.0",
"nodemon": "^2.0.7",
"postcss-css-variables": "^0.11.0",
"postcss-nested": "^3.0.0",
"puppeteer": "^5.5.0",
"sass-loader": "^10.1.0",
"postcss-nested": "^5.0.5",
"prisma": "^2.29.1",
"sass-loader": "^12.1.0",
"ts-node": "^9.1.1",
"typescript": "^4.2.2",
"vue-jest": "^3.0.7"
},
"eslintConfig": {
"extends": [
"aqua/node",
"aqua/vue"
"marine/node"
],
"parserOptions": {
"parser": "babel-eslint",
"sourceType": "module"
},
"settings": {
"import/resolver": {
"nuxt": {
"nuxtSrcDir": "./src/site",
"extensions": [
".js",
".vue"
]
}
}
"rules": {
"comma-dangle": [
"error",
"never"
]
}
},
"prisma": {
"schema": "src/api/prisma/schema.prisma"
},
"nodemonConfig": {
"watch": [
"src/api/*"
@ -149,6 +159,6 @@
"images"
],
"volta": {
"node": "14.17.0"
"node": "16.3.0"
}
}

120
src/api/main.ts Normal file
View File

@ -0,0 +1,120 @@
// import express from 'express';
import fastify from 'fastify';
import helmet from 'fastify-helmet';
import cors from 'fastify-cors';
import formbody from 'fastify-formbody';
import fstatic from 'fastify-static';
import path from 'path';
import rateLimit from 'fastify-rate-limit';
import jetpack from 'fs-jetpack';
// import cron from 'cron';
// @ts-ignore - nuxt types can't be found - https://github.com/nuxt/nuxt.js/issues/7651
import { Nuxt, Builder } from 'nuxt';
import nuxtDefaults from './structures/nuxt';
import Routes from './structures/routes';
// const server = express();
const server = fastify({
trustProxy: true,
logger: {
serializers: {
req(request) {
return {
ip: request.hostname,
method: request.method,
url: request.url
};
},
res(reply) {
return {
statusCode: reply.statusCode
};
}
},
prettyPrint: {
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss.l',
ignore: 'pid,hostname,reqId,req,res,responseTime',
/*
TODO:
Find a way to merge incoming request and request completed into 1 line using
messageFormat as a function instead of a string.
Maybe even set a new flag under log like log.app instead of log.info
to be able to print logs without req and res information
[2021-06-21 19:22:21.553] INFO: incoming request [localhost:5000 - GET /api/verify - ]
[2021-06-21 19:22:21.556] INFO: request completed [ - - 401]
*/
messageFormat: '{msg} [{req.ip} - {req.method} {req.url} - {res.statusCode}]'
}
},
connectionTimeout: 600000
});
const start = async () => {
// Create the folders needed for uploads
jetpack.dir('uploads/chunks');
jetpack.dir('uploads/thumbs/square');
jetpack.dir('uploads/thumbs/preview');
// Create the server and set it up
void server.register(helmet);
void server.register(cors, {
allowedHeaders: ['Accept', 'Authorization', 'Cache-Control', 'X-Requested-With', 'Content-Type', 'albumId', 'finishedChunks']
});
void server.register(formbody);
server.addHook('onRequest', (req, reply, next) => {
// This bypasses the headers.accept for album download, since it's accesed directly through the browser.
if ((req.url.includes('/api/album/') || req.url.includes('/zip')) && req.method === 'GET') return next();
// This bypasses the headers.accept if we are accessing the frontend
if (!req.url.includes('/api/') && req.method === 'GET') return next();
if (req.headers.accept?.includes('application/vnd.chibisafe.json')) return next();
return reply.status(405).send({ message: 'Incorrect `Accept` header provided' });
});
// Apply rate limiting to the api only
// TODO: Find a way to only apply this to /api routes
void server.register(rateLimit, {
global: false,
max: parseInt(process.env.RATE_LIMIT_MAX ?? '5', 10),
timeWindow: parseInt(process.env.RATE_LIMIT_WINDOW ?? '2000', 10)
});
// Scan and load routes into express
await Routes.load(server);
// Listen for incoming connections
void server.listen(process.env.port ?? 5000);
// Serve the uploads
void server.register(fstatic, {
root: path.join(__dirname, '../../uploads')
});
const nuxtConfig = await nuxtDefaults();
nuxtConfig.dev = !(process.env.NODE_ENV === 'production');
const nuxt = new Nuxt(nuxtConfig);
if (nuxtConfig.dev) {
const builder = new Builder(nuxt);
await builder.build();
} else {
await nuxt.ready();
}
void server.register(nuxt.render);
/*
const isProd = process.env.NODE_ENV === 'production';
const nuxt = await loadNuxt(isProd ? 'start' : 'dev');
void server.register(nuxt.render);
if (!isProd) build(nuxt);
*/
// TODO: move into the database config. (we can just show the crontab line for start, later on we can add dropdowns and stuff)
// new cron.CronJob('0 0 * * * *', Util.saveStatsToDb, null, true);
};
export const log = server.log;
void start();

View File

@ -0,0 +1,7 @@
import type { FastifyReply, HookHandlerDoneFunction } from 'fastify';
import type { RequestWithUser } from '../structures/interfaces';
export default (req: RequestWithUser, res: FastifyReply, next: HookHandlerDoneFunction) => {
if (!req.user.isAdmin) return res.status(401).send({ message: 'Permission denied' });
next();
};

View File

@ -0,0 +1,21 @@
import type { FastifyReply, HookHandlerDoneFunction } from 'fastify';
import type { RequestWithUser } from '../structures/interfaces';
import prisma from '../structures/database';
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

@ -0,0 +1,37 @@
import type { FastifyReply, HookHandlerDoneFunction } from 'fastify';
import type { RequestWithUser } from '../structures/interfaces';
import JWT from 'jsonwebtoken';
import prisma from '../structures/database';
interface Decoded {
sub: number;
}
export default (req: RequestWithUser, res: FastifyReply, next: HookHandlerDoneFunction) => {
if (!req.headers.authorization) return res.status(401).send({ message: 'No authorization header provided' });
const token = req.headers.authorization.split(' ')[1];
if (!token) return res.status(401).send({ message: 'No authorization header provided' });
// eslint-disable-next-line @typescript-eslint/no-misused-promises
JWT.verify(token, process.env.secret ?? '', async (error, decoded) => {
if (error) return res.status(401).send({ message: 'Invalid token' });
const id = (decoded as Decoded | undefined)?.sub ?? null;
if (!id) return res.status(401).send({ message: 'Invalid authorization' });
const user = await prisma.users.findFirst({
where: {
id
},
select: {
id: true,
username: true,
isAdmin: true
}
});
if (!user) return res.status(401).send({ message: 'User doesn\'t exist' });
req.user = user;
next();
});
};

View File

@ -0,0 +1,15 @@
import type { FastifyRequest, FastifyReply, HookHandlerDoneFunction } from 'fastify';
import prisma from '../structures/database';
export default async (req: FastifyRequest, res: FastifyReply, next: HookHandlerDoneFunction) => {
const banned = await prisma.bans.findFirst({
where: {
ip: req.ip
}
});
if (banned) {
return res.status(401).send({ message: 'This IP has been banned' });
}
next();
};

View File

@ -0,0 +1,119 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["selectRelationCount"]
}
datasource db {
provider = "sqlite"
url = "file:../../../database/database.sqlite"
shadowDatabaseUrl = "file:../../../database/shadow.sqlite"
}
model albums {
id Int @id @default(autoincrement())
userId Int
name String
zippedAt DateTime?
createdAt DateTime @default(now())
editedAt DateTime?
nsfw Boolean @default(false)
@@unique([userId, name], name: "albums_userid_name_unique")
}
model albumsFiles {
id Int @id @default(autoincrement())
albumId Int
fileId Int
@@unique([albumId, fileId], name: "albumsfiles_albumid_fileid_unique")
}
model albumsLinks {
id Int @id @default(autoincrement())
albumId Int
linkId Int @unique
}
model bans {
id Int @id @default(autoincrement())
ip String
createdAt DateTime @default(now())
}
model fileTags {
id Int @id @default(autoincrement())
fileId Int
tagId Int
@@unique([fileId, tagId], name: "filetags_fileid_tagid_unique")
}
model files {
id Int @id @default(autoincrement())
userId Int?
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 @unique
views Int @default(0)
enabled Boolean @default(true)
enableDownload Boolean @default(false)
expiresAt DateTime?
createdAt DateTime @default(now())
editedAt DateTime?
@@unique([userId, identifier], name: "links_userid_identifier_unique")
}
model settings {
id Int @id @default(autoincrement())
key String
value String
}
model statistics {
id Int @id @default(autoincrement())
batchId Int?
type String?
// TODO: This was JSON before so make sure to stringify and parse when saving stats
data String?
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 @default(now())
editedAt DateTime?
@@unique([userId, name], name: "tags_userid_name_unique")
}
model users {
id Int @id @default(autoincrement())
username String @unique
password String
enabled Boolean @default(true)
isAdmin Boolean @default(false)
apiKey String? @unique
passwordEditedAt DateTime?
apiKeyEditedAt DateTime?
createdAt DateTime @default(now())
editedAt DateTime?
}

View File

@ -0,0 +1,23 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import prisma from '../../../../structures/database';
interface body {
ip: string;
}
export const middlewares = ['auth', 'admin'];
export const run = async (req: FastifyRequest, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
// const { username, password }: { username: string; password: string } = req.body;
const { ip } = req.body as body;
if (!ip) return res.status(400).send({ message: 'No ip provided' });
await prisma.bans.create({
data: {
ip
}
});
return res.send({
message: 'Successfully banned the ip'
});
};

View File

@ -1,25 +0,0 @@
const Route = require('../../structures/Route');
class banIP extends Route {
constructor() {
super('/admin/ban/ip', 'post', { adminOnly: true });
}
async run(req, res, db) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { ip } = req.body;
if (!ip) return res.status(400).json({ message: 'No ip provided' });
try {
await db.table('bans').insert({ ip });
} catch (error) {
return super.error(res, error);
}
return res.json({
message: 'Successfully banned the ip'
});
}
}
module.exports = banIP;

View File

@ -0,0 +1,58 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import prisma from '../../../../structures/database';
import { constructFilePublicLink } from '../../../../utils/Util';
import type { User } from '../../../../structures/interfaces';
interface params {
id: number;
}
interface UserWithFileCount extends User {
fileCount?: number;
}
export const middlewares = ['auth', 'admin'];
export const run = async (req: FastifyRequest, res: FastifyReply) => {
const { id } = req.params as params;
if (!id) return res.status(400).send({ message: 'Invalid file ID supplied' });
const file = await prisma.files.findUnique({
where: {
id
}
});
if (!file) return res.status(404).send({ message: 'File doesn\'t exist' });
let user;
if (file.userId) {
user = await prisma.users.findUnique({
where: {
id: file.userId
},
select: {
id: true,
username: true,
enabled: true,
createdAt: true,
editedAt: true,
isAdmin: true
}
});
if (user) {
// TODO: ???
(user as unknown as UserWithFileCount).fileCount = await prisma.files.count({
where: {
userId: user.id
}
});
}
}
const extendedFile = constructFilePublicLink(req, file);
return res.send({
message: 'Successfully retrieved file',
file: extendedFile,
user
});
};

View File

@ -1,32 +0,0 @@
const Route = require('../../structures/Route');
const Util = require('../../utils/Util');
class filesGET extends Route {
constructor() {
super('/admin/file/:id', 'get', { adminOnly: true });
}
async run(req, res, db) {
const { id } = req.params;
if (!id) return res.status(400).json({ message: 'Invalid file ID supplied' });
let file = await db.table('files').where({ id }).first();
const user = await db.table('users')
.select('id', 'username', 'enabled', 'createdAt', 'editedAt', 'apiKeyEditedAt', 'isAdmin')
.where({ id: file.userId })
.first();
file = Util.constructFilePublicLink(req, file);
// Additional relevant data
const filesFromUser = await db.table('files').where({ userId: user.id }).select('id');
user.fileCount = filesFromUser.length;
return res.json({
message: 'Successfully retrieved file',
file,
user
});
}
}
module.exports = filesGET;

View File

@ -1,27 +0,0 @@
const Route = require('../../structures/Route');
class unBanIP extends Route {
constructor() {
super('/admin/unban/ip', 'post', { adminOnly: true });
}
async run(req, res, db) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { ip } = req.body;
if (!ip) return res.status(400).json({ message: 'No ip provided' });
try {
await db.table('bans')
.where({ ip })
.delete();
} catch (error) {
return super.error(res, error);
}
return res.json({
message: 'Successfully unbanned the ip'
});
}
}
module.exports = unBanIP;

View File

@ -0,0 +1,33 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import prisma from '../../../../structures/database';
interface body {
ip: string;
}
export const middlewares = ['auth', 'admin'];
export const run = async (req: FastifyRequest, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
// const { username, password }: { username: string; password: string } = req.body;
const { ip } = req.body as body;
if (!ip) return res.status(400).send({ message: 'No ip provided' });
const record = await prisma.bans.findFirst({
where: {
ip
}
});
if (record) {
await prisma.bans.delete({
where: {
id: record.id
}
});
}
return res.send({
message: 'Successfully unbanned the ip'
});
};

View File

@ -0,0 +1,58 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import prisma from '../../../../structures/database';
import { constructFilePublicLink } from '../../../../utils/Util';
export const middlewares = ['auth', 'admin'];
interface params {
id: number;
}
export const run = async (req: FastifyRequest, res: FastifyReply) => {
const { id } = req.params as params;
if (!id) return res.status(400).send({ message: 'Invalid user ID supplied' });
const { page = 1, limit = 100 } = req.query as { page: number; limit: number };
const user = await prisma.users.findUnique({
where: {
id
},
select: {
id: true,
username: true,
enabled: true,
isAdmin: true,
createdAt: true,
editedAt: true,
apiKeyEditedAt: true
}
});
if (!user) return res.status(404).send({ message: 'User not found' });
const count = await prisma.files.count({
where: {
userId: user.id
}
});
const files = await prisma.files.findMany({
take: limit,
skip: (page - 1) * limit,
where: {
userId: user.id
}
});
const readyFiles = [];
for (const file of files) {
readyFiles.push(constructFilePublicLink(req, file));
}
return res.send({
message: 'Successfully retrieved users',
user,
files: readyFiles,
count
});
};

View File

@ -0,0 +1,29 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../../structures/interfaces';
import prisma from '../../../../structures/database';
interface body {
id: number;
}
export const middlewares = ['auth', 'admin'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
const { id } = req.body as body;
if (!id) return res.status(400).send({ message: 'No id provided' });
if (id === req.user.id) return res.status(400).send({ message: 'You can\'t apply this action to yourself' });
await prisma.users.update({
where: {
id
},
data: {
isAdmin: false
}
});
return res.send({
message: 'Successfully demoted user'
});
};

View File

@ -0,0 +1,29 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../../structures/interfaces';
import prisma from '../../../../structures/database';
interface body {
id: number;
}
export const middlewares = ['auth', 'admin'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
const { id } = req.body as body;
if (!id) return res.status(400).send({ message: 'No id provided' });
if (id === req.user.id) return res.status(400).send({ message: 'You can\'t apply this action to yourself' });
await prisma.users.update({
where: {
id
},
data: {
enabled: false
}
});
return res.send({
message: 'Successfully disabled user'
});
};

View File

@ -0,0 +1,29 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../../structures/interfaces';
import prisma from '../../../../structures/database';
interface body {
id: number;
}
export const middlewares = ['auth', 'admin'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
const { id } = req.body as body;
if (!id) return res.status(400).send({ message: 'No id provided' });
if (id === req.user.id) return res.status(400).send({ message: 'You can\'t apply this action to yourself' });
await prisma.users.update({
where: {
id
},
data: {
enabled: true
}
});
return res.send({
message: 'Successfully enabled user'
});
};

View File

@ -0,0 +1,29 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../../structures/interfaces';
import prisma from '../../../../structures/database';
interface body {
id: number;
}
export const middlewares = ['auth', 'admin'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
const { id } = req.body as body;
if (!id) return res.status(400).send({ message: 'No id provided' });
if (id === req.user.id) return res.status(400).send({ message: 'You can\'t apply this action to yourself' });
await prisma.users.update({
where: {
id
},
data: {
isAdmin: true
}
});
return res.send({
message: 'Successfully promoted user'
});
};

View File

@ -0,0 +1,20 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import { deleteAllFilesFromUser } from '../../../../utils/Util';
interface body {
id: number;
}
export const middlewares = ['auth', 'admin'];
export const run = async (req: FastifyRequest, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
const { id } = req.body as body;
if (!id) return res.status(400).send({ message: 'No id provided' });
await deleteAllFilesFromUser(id);
return res.send({
message: 'Successfully demoted user'
});
};

View File

@ -1,29 +0,0 @@
const Route = require('../../structures/Route');
class userDemote extends Route {
constructor() {
super('/admin/users/demote', 'post', { adminOnly: true });
}
async run(req, res, db, user) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { id } = req.body;
if (!id) return res.status(400).json({ message: 'No id provided' });
if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' });
try {
await db.table('users')
.where({ id })
.update({ isAdmin: false })
.wasMutated();
} catch (error) {
return super.error(res, error);
}
return res.json({
message: 'Successfully demoted user'
});
}
}
module.exports = userDemote;

View File

@ -1,29 +0,0 @@
const Route = require('../../structures/Route');
class userDisable extends Route {
constructor() {
super('/admin/users/disable', 'post', { adminOnly: true });
}
async run(req, res, db, user) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { id } = req.body;
if (!id) return res.status(400).json({ message: 'No id provided' });
if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' });
try {
await db.table('users')
.where({ id })
.update({ enabled: false })
.wasMutated();
} catch (error) {
return super.error(res, error);
}
return res.json({
message: 'Successfully disabled user'
});
}
}
module.exports = userDisable;

View File

@ -1,29 +0,0 @@
const Route = require('../../structures/Route');
class userEnable extends Route {
constructor() {
super('/admin/users/enable', 'post', { adminOnly: true });
}
async run(req, res, db, user) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { id } = req.body;
if (!id) return res.status(400).json({ message: 'No id provided' });
if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' });
try {
await db.table('users')
.where({ id })
.update({ enabled: true })
.wasMutated();
} catch (error) {
return super.error(res, error);
}
return res.json({
message: 'Successfully enabled user'
});
}
}
module.exports = userEnable;

View File

@ -1,55 +0,0 @@
const Route = require('../../structures/Route');
const Util = require('../../utils/Util');
class usersGET extends Route {
constructor() {
super('/admin/users/:id', 'get', { adminOnly: true });
}
async run(req, res, db) {
const { id } = req.params;
if (!id) return res.status(400).json({ message: 'Invalid user ID supplied' });
try {
const user = await db.table('users')
.select('id', 'username', 'enabled', 'createdAt', 'editedAt', 'apiKeyEditedAt', 'isAdmin')
.where({ id })
.first();
let count = 0;
let files = db.table('files')
.where({ userId: user.id })
.orderBy('id', 'desc');
const { page, limit = 100 } = req.query;
if (page && page >= 0) {
files = await files.offset((page - 1) * limit).limit(limit);
const dbRes = await db.table('files')
.count('* as count')
.where({ userId: user.id })
.first();
count = dbRes.count;
} else {
files = await files; // execute the query
count = files.length;
}
for (let file of files) {
file = Util.constructFilePublicLink(req, file);
}
return res.json({
message: 'Successfully retrieved user',
user,
files,
count
});
} catch (error) {
return super.error(res, error);
}
}
}
module.exports = usersGET;

View File

@ -1,28 +0,0 @@
const Route = require('../../structures/Route');
class userPromote extends Route {
constructor() {
super('/admin/users/promote', 'post', { adminOnly: true });
}
async run(req, res, db, user) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { id } = req.body;
if (!id) return res.status(400).json({ message: 'No id provided' });
if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' });
try {
await db.table('users')
.where({ id })
.update({ isAdmin: true });
} catch (error) {
return super.error(res, error);
}
return res.json({
message: 'Successfully promoted user'
});
}
}
module.exports = userPromote;

View File

@ -1,26 +0,0 @@
const Route = require('../../structures/Route');
const Util = require('../../utils/Util');
class userDemote extends Route {
constructor() {
super('/admin/users/purge', 'post', { adminOnly: true });
}
async run(req, res) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { id } = req.body;
if (!id) return res.status(400).json({ message: 'No id provided' });
try {
await Util.deleteAllFilesFromUser(id);
} catch (error) {
return super.error(res, error);
}
return res.json({
message: 'Successfully deleted the user\'s files'
});
}
}
module.exports = userDemote;

View File

@ -0,0 +1,21 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import prisma from '../../../structures/database';
export const middlewares = ['auth', 'admin'];
export const run = async (req: FastifyRequest, res: FastifyReply) => {
const users = await prisma.users.findMany({
select: {
id: true,
username: true,
enabled: true,
isAdmin: true,
createdAt: true
}
});
return res.send({
message: 'Successfully retrieved users',
users
});
};

View File

@ -1,23 +0,0 @@
const Route = require('../../structures/Route');
class usersGET extends Route {
constructor() {
super('/admin/users', 'get', { adminOnly: true });
}
async run(req, res, db) {
try {
const users = await db.table('users')
.select('id', 'username', 'enabled', 'isAdmin', 'createdAt');
return res.json({
message: 'Successfully retrieved users',
users
});
} catch (error) {
return super.error(res, error);
}
}
}
module.exports = usersGET;

View File

@ -0,0 +1,26 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../../structures/interfaces';
import prisma from '../../../../structures/database';
interface params {
id: number;
}
export const middlewares = ['auth'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
const { id } = req.params as params;
if (!id) return res.status(400).send({ message: 'Invalid id supplied' });
const links = await prisma.links.findMany({
where: {
albumId: id,
userId: req.user.id
}
});
return res.send({
message: 'Successfully retrieved links',
links
});
};

View File

@ -0,0 +1,43 @@
import type { FastifyReply } from 'fastify';
import prisma from '../../../../../structures/database';
import { RequestWithUser } from '../../../../../structures/interfaces';
interface body {
identifier: string;
}
export const middlewares = ['auth'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
if (!req.params) return res.status(400).send({ message: 'No body provided' });
const { identifier } = req.body as body;
if (!identifier) return res.status(400).send({ message: 'No identifier provided' });
const link = await prisma.links.findFirst({
where: {
userId: req.user.id,
identifier
}
});
if (!link) return res.status(400).send({ message: 'No link found' });
await prisma.links.delete({
where: {
links_userid_identifier_unique: {
userId: req.user.id,
identifier
}
}
});
await prisma.albumsLinks.delete({
where: {
linkId: link.id
}
});
return res.send({
message: 'Successfully deleted the link'
});
};

View File

@ -0,0 +1,44 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../../structures/interfaces';
import prisma from '../../../../structures/database';
interface body {
identifier: string;
enableDownload: boolean;
expiresAt: Date;
}
export const middlewares = ['auth'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
if (!req.params) return res.status(400).send({ message: 'No body provided' });
const { identifier, enableDownload, expiresAt } = req.body as body;
if (!identifier) return res.status(400).send({ message: 'No identifier provided' });
const link = await prisma.links.findFirst({
where: {
userId: req.user.id,
identifier
}
});
if (!link) return res.status(400).send({ message: 'No link found' });
const updateObj = {
enableDownload: enableDownload || false,
expiresAt // This one should be null if not supplied
};
await prisma.links.update({
where: {
identifier
},
data: {
...updateObj
}
});
return res.send({
message: 'Successfully edited link'
});
};

View File

@ -0,0 +1,61 @@
import type { FastifyReply } from 'fastify';
import prisma from '../../../../structures/database';
import { RequestWithUser } from '../../../../structures/interfaces';
import { getUniqueAlbumIdentifier } from '../../../../utils/Util';
interface body {
albumId: number;
identifier?: string;
}
export const middlewares = ['auth'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
if (!req.params) return res.status(400).send({ message: 'No body provided' });
const { albumId } = req.body as body;
if (!albumId) return res.status(400).send({ message: 'No albumId provided' });
const exists = await prisma.albums.findFirst({
where: {
id: albumId,
userId: req.user.id
}
});
if (!exists) return res.status(400).send({ message: 'Album doesn\t exist' });
let { identifier } = req.body as body;
if (identifier) {
if (!req.user.isAdmin) return res.status(401).send({ message: 'Only administrators can create custom links' });
if (!(/^[a-zA-Z0-9-_]+$/.test(identifier))) return res.status(400).send({ message: 'Only alphanumeric, dashes, and underscore characters are allowed' });
const identifierExists = await prisma.links.findFirst({
where: {
identifier
}
});
if (identifierExists) return res.status(400).send({ message: 'Album with this identifier already exists' });
} else {
identifier = await getUniqueAlbumIdentifier();
if (!identifier) return res.status(500).send({ message: 'There was a problem allocating a link for your album' });
}
const insertObj = {
identifier,
userId: req.user.id,
albumId,
enabled: true,
enableDownload: true,
expiresAt: null,
views: 0
};
await prisma.links.create({
data: insertObj
});
return res.send({
message: 'Successfully created link',
data: insertObj
});
};

View File

@ -1,35 +0,0 @@
const Route = require('../../../structures/Route');
class linkDELETE extends Route {
constructor() {
super('/album/link/delete/:identifier', 'delete');
}
async run(req, res, db, user) {
const { identifier } = req.params;
if (!identifier) return res.status(400).json({ message: 'Invalid identifier supplied' });
try {
const link = await db.table('links')
.where({ identifier, userId: user.id })
.first();
if (!link) return res.status(400).json({ message: 'Identifier doesn\'t exist or doesnt\'t belong to the user' });
await db.table('links')
.where({ id: link.id })
.delete();
await db.table('albumsLinks')
.where({ linkId: link.id })
.delete();
} catch (error) {
return super.error(res, error);
}
return res.json({
message: 'Successfully deleted link'
});
}
}
module.exports = linkDELETE;

View File

@ -1,38 +0,0 @@
const Route = require('../../../structures/Route');
class linkEditPOST extends Route {
constructor() {
super('/album/link/edit', 'post');
}
async run(req, res, db, user) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { identifier, enableDownload, expiresAt } = req.body;
if (!identifier) return res.status(400).json({ message: 'Invalid album identifier supplied' });
/*
Make sure the link exists
*/
const link = await db
.table('links')
.where({ identifier, userId: user.id })
.first();
if (!link) return res.status(400).json({ message: "The link doesn't exist or doesn't belong to the user" });
try {
const updateObj = {
enableDownload: enableDownload || false,
expiresAt // This one should be null if not supplied
};
await db
.table('links')
.where({ identifier })
.update(updateObj);
return res.json({ message: 'Editing the link was successful', data: updateObj });
} catch (error) {
return super.error(res, error);
}
}
}
module.exports = linkEditPOST;

View File

@ -1,68 +0,0 @@
const Route = require('../../../structures/Route');
const Util = require('../../../utils/Util');
class linkPOST extends Route {
constructor() {
super('/album/link/new', 'post');
}
async run(req, res, db, user) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { albumId } = req.body;
if (!albumId) return res.status(400).json({ message: 'No album provided' });
/*
Make sure the album exists
*/
const exists = await db
.table('albums')
.where({ id: albumId, userId: user.id })
.first();
if (!exists) return res.status(400).json({ message: 'Album doesn\t exist' });
let { identifier } = req.body;
if (identifier) {
if (!user.isAdmin) return res.status(401).json({ message: 'Only administrators can create custom links' });
if (!(/^[a-zA-Z0-9-_]+$/.test(identifier))) return res.status(400).json({ message: 'Only alphanumeric, dashes, and underscore characters are allowed' });
/*
Make sure that the id doesn't already exists in the database
*/
const idExists = await db
.table('links')
.where({ identifier })
.first();
if (idExists) return res.status(400).json({ message: 'Album with this identifier already exists' });
} else {
/*
Try to allocate a new identifier in the database
*/
identifier = await Util.getUniqueAlbumIdentifier();
if (!identifier) return res.status(500).json({ message: 'There was a problem allocating a link for your album' });
}
try {
const insertObj = {
identifier,
userId: user.id,
albumId,
enabled: true,
enableDownload: true,
expiresAt: null,
views: 0
};
await db.table('links').insert(insertObj).wasMutated();
return res.json({
message: 'The link was created successfully',
data: insertObj
});
} catch (error) {
return super.error(res, error);
}
}
}
module.exports = linkPOST;

View File

@ -1,22 +0,0 @@
const Route = require('../../../structures/Route');
class linkPOST extends Route {
constructor() {
super('/album/:id/links', 'get');
}
async run(req, res, db, user) {
const { id } = req.params;
if (!id) return res.status(400).json({ message: 'Invalid id supplied' });
const links = await db.table('links')
.where({ albumId: id, userId: user.id });
return res.json({
message: 'Successfully retrieved links',
links
});
}
}
module.exports = linkPOST;

View File

@ -0,0 +1,44 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import prisma from '../../../structures/database';
import bcrypt from 'bcryptjs';
import JWT from 'jsonwebtoken';
interface body {
username: string;
password: string;
}
export const run = async (req: FastifyRequest, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
// const { username, password }: { username: string; password: string } = req.body;
const { username, password } = req.body as body;
if (!username || !password) return res.status(400).send();
const user = await prisma.users.findFirst({
where: {
username
}
});
if (!user) return res.status(401).send({ message: 'User doesn\'t exist' });
const comparePassword = await bcrypt.compare(password, user.password);
if (!comparePassword) return res.status(401).send({ message: 'Invalid authorization.' });
const jwt = JWT.sign({
iss: 'chibisafe',
sub: user.id,
iat: new Date().getTime()
}, process.env.secret ?? '', { expiresIn: '30d' });
return res.send({
message: 'Successfully logged in.',
user: {
id: user.id,
username: user.username,
apiKey: user.apiKey,
isAdmin: user.isAdmin
},
token: jwt
});
};

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

@ -0,0 +1,45 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import prisma from '../../../structures/database';
import bcrypt from 'bcryptjs';
interface body {
username: string;
password: string;
}
export const run = async (req: FastifyRequest, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
const { username, password } = req.body as body;
if (!username || !password) return res.status(400).send();
if (username.length < 4 || username.length > 32) {
return res.status(400).send({ message: 'Username must have 4-32 characters' });
}
if (password.length < 6 || password.length > 64) {
return res.status(400).send({ message: 'Password must have 6-64 characters' });
}
const exists = await prisma.users.findFirst({
where: {
username
}
});
if (exists) return res.status(401).send({ message: 'Username already exists' });
let hash;
try {
hash = await bcrypt.hash(password, 10);
} catch (err) {
req.log.error(err);
return res.status(401).send({ message: 'There was a problem processing your account' });
}
await prisma.users.create({
data: {
username,
password: hash
}
});
return res.send({ message: 'The account was created successfully' });
};

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

@ -0,0 +1,10 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../../structures/interfaces';
import { getConfig } from '../../../../utils/Util';
export const middlewares = ['auth', 'admin'];
export const run = async (req: RequestWithUser, res: FastifyReply) => res.send({
message: 'Successfully retrieved config',
config: (await getConfig())
});

View File

@ -0,0 +1,27 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../structures/interfaces';
import { getConfig } from '../../../utils/Util';
export const middlewares = ['auth', 'admin'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
const config = await getConfig();
return res.send({
message: 'Successfully retrieved config',
config: {
version: process.env.npm_package_version,
serviceName: config.serviceName,
maxUploadSize: config.maxSize,
filenameLength: config.generatedFilenameLength,
albumLinkLength: config.generatedAlbumLength,
chunkSize: config.chunkSize,
publicMode: config.publicMode,
userAccounts: config.userAccounts,
metaThemeColor: config.metaThemeColor,
metaDescription: config.metaDescription,
metaKeywords: config.metaKeywords,
metaTwitterHandle: config.metaTwitterHandle
}
});
};

View File

@ -1,17 +0,0 @@
const Route = require('../../structures/Route');
const Util = require('../../utils/Util');
class configGET extends Route {
constructor() {
super('/service/config/all', 'get', { adminOnly: true });
}
run(req, res) {
return res.json({
message: 'Successfully retrieved config',
config: Util.config
});
}
}
module.exports = configGET;

View File

@ -1,30 +0,0 @@
const Route = require('../../structures/Route');
const Util = require('../../utils/Util');
class configGET extends Route {
constructor() {
super('/service/config', 'get', { bypassAuth: true });
}
run(req, res) {
return res.json({
message: 'Successfully retrieved config',
config: {
version: process.env.npm_package_version,
serviceName: Util.config.serviceName,
maxUploadSize: Util.config.maxSize,
filenameLength: Util.config.generatedFilenameLength,
albumLinkLength: Util.config.generatedAlbumLength,
chunkSize: Util.config.chunkSize,
publicMode: Util.config.publicMode,
userAccounts: Util.config.userAccounts,
metaThemeColor: Util.config.metaThemeColor,
metaDescription: Util.config.metaDescription,
metaKeywords: Util.config.metaKeywords,
metaTwitterHandle: Util.metaTwitterHandle
}
});
}
}
module.exports = configGET;

View File

@ -0,0 +1,11 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../structures/interfaces';
export const middlewares = ['auth', 'admin'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
void res.send({
message: 'Successfully triggered restart'
});
process.exit(0);
};

View File

@ -1,14 +0,0 @@
const Route = require('../../structures/Route');
class restartPOST extends Route {
constructor() {
super('/service/restart', 'post', { adminOnly: true });
}
run(req, res) {
res.json({ message: 'Restarting...' });
process.exit(0);
}
}
module.exports = restartPOST;

View File

@ -1,15 +0,0 @@
const Route = require('../../structures/Route');
class versionGET extends Route {
constructor() {
super('/version', 'get', { bypassAuth: true });
}
run(req, res) {
return res.json({
version: process.env.npm_package_version
});
}
}
module.exports = versionGET;

View File

@ -1,34 +0,0 @@
const randomstring = require('randomstring');
const moment = require('moment');
const { dump } = require('dumper.js');
const Route = require('../../structures/Route');
class apiKeyPOST extends Route {
constructor() {
super('/user/apikey/change', 'post');
}
async run(req, res, db, user) {
const now = moment.utc().toDate();
const apiKey = randomstring.generate(64);
try {
await db.table('users')
.where({ id: user.id })
.update({
apiKey,
apiKeyEditedAt: now
});
} catch (error) {
dump(error);
return res.status(401).json({ message: 'There was a problem processing your account' });
}
return res.json({
message: 'Successfully created new api key',
apiKey
});
}
}
module.exports = apiKeyPOST;

View File

@ -0,0 +1,26 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../../structures/interfaces';
import prisma from '../../../../structures/database';
import { utc } from 'moment';
import randomstring from 'randomstring';
export const middlewares = ['auth'];
export const run = async (req: RequestWithUser, res: FastifyReply) => {
const now = utc().toDate();
const apiKey = randomstring.generate(64);
await prisma.users.update({
where: {
id: req.user.id
},
data: {
apiKey,
apiKeyEditedAt: now
}
});
return res.send({
message: 'Successfully created new api key',
apiKey
});
};

View File

@ -1,46 +0,0 @@
const bcrypt = require('bcrypt');
const moment = require('moment');
const Route = require('../../structures/Route');
const log = require('../../utils/Log');
class changePasswordPOST extends Route {
constructor() {
super('/user/password/change', 'post');
}
async run(req, res, db, user) {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { password, newPassword } = req.body;
if (!password || !newPassword) return res.status(401).json({ message: 'Invalid body provided' });
if (password === newPassword) return res.status(400).json({ message: 'Passwords have to be different' });
/*
Checks if the password is right
*/
const comparePassword = await bcrypt.compare(password, user.password);
if (!comparePassword) return res.status(401).json({ message: 'Current password is incorrect' });
if (newPassword.length < 6 || newPassword.length > 64) {
return res.status(400).json({ message: 'Password must have 6-64 characters' });
}
let hash;
try {
hash = await bcrypt.hash(newPassword, 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' });
}
const now = moment.utc().toDate();
await db.table('users').where('id', user.id).update({
password: hash,
passwordEditedAt: now
});
return res.json({ message: 'The password was changed successfully' });
}
}
module.exports = changePasswordPOST;

View File

@ -0,0 +1,11 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../structures/interfaces';
export const middlewares = ['auth'];
export const run = async (req: RequestWithUser, res: FastifyReply) => res.send({
message: 'Successfully retrieved user',
user: {
...req.user
}
});

View File

@ -0,0 +1,54 @@
import type { FastifyReply } from 'fastify';
import type { RequestWithUser } from '../../../../structures/interfaces';
import prisma from '../../../../structures/database';
import bcrypt from 'bcryptjs';
import { utc } from 'moment';
interface body {
password: string;
newPassword: string;
}
export const run = async (req: RequestWithUser, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
const { password, newPassword } = req.body as body;
if (!password || !newPassword) return res.status(400).send({ message: 'Invalid body provided' });
if (password === newPassword) return res.status(400).send({ message: 'Passwords have to be different' });
const user = await prisma.users.findUnique({
where: {
id: req.user.id
},
select: {
password: true
}
});
const comparePassword = await bcrypt.compare(password, user?.password ?? '');
if (!comparePassword) return res.status(401).send({ message: 'Current password is incorrect' });
if (newPassword.length < 6 || newPassword.length > 64) {
return res.status(400).send({ message: 'Password must have 6-64 characters' });
}
let hash;
try {
hash = await bcrypt.hash(newPassword, 10);
} catch (err) {
req.log.error(err);
return res.status(401).send({ message: 'There was a problem processing your account' });
}
const now = utc().toDate();
await prisma.users.update({
where: {
id: req.user.id
},
data: {
password: hash,
passwordEditedAt: now
}
});
return res.send({ message: 'TThe password was changed successfully' });
};

View File

@ -1,21 +0,0 @@
const Route = require('../../structures/Route');
class usersGET extends Route {
constructor() {
super('/users/me', 'get');
}
run(req, res, db, user) {
return res.json({
message: 'Successfully retrieved user',
user: {
id: user.id,
username: user.username,
isAdmin: user.isAdmin,
apiKey: user.apiKey
}
});
}
}
module.exports = usersGET;

View File

@ -0,0 +1,6 @@
import type { FastifyReply } from 'fastify';
import { RequestWithUser } from '../../structures/interfaces';
export const middlewares = ['auth'];
export const run = (req: RequestWithUser, res: FastifyReply) => res.status(200).send(req.user);

View File

@ -1,21 +0,0 @@
const Route = require('../structures/Route');
class verifyGET extends Route {
constructor() {
super('/verify', 'get');
}
run(req, res, db, user) {
return res.json({
message: 'Successfully verified token',
user: {
id: user.id,
username: user.username,
isAdmin: user.isAdmin,
apiKey: user.apiKey
}
});
}
}
module.exports = verifyGET;

View File

@ -0,0 +1,5 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
export const run = async (req: FastifyRequest, res: FastifyReply) => res.send({
version: process.env.npm_package_version
});

View File

@ -1,15 +0,0 @@
require('dotenv').config();
const Util = require('../utils/Util');
const start = async () => {
try {
await Util.writeConfigToDb(Util.getEnvironmentDefaults());
console.log('Configuration overwriten, you can now start chibisafe');
process.exit(0);
} catch (error) {
console.error(error);
}
};
start();

View File

@ -0,0 +1,23 @@
import dotenv from 'dotenv';
dotenv.config();
import { writeConfigToDb, getEnvironmentDefaults } from '../utils/Util';
const start = async () => {
try {
const defaults = getEnvironmentDefaults();
const keys = Object.keys(defaults);
for (const item of keys) {
await writeConfigToDb({
key: item,
value: defaults[item]
});
}
console.log('Configuration overwriten, you can now start chibisafe');
process.exit(0);
} catch (error) {
console.error(error);
}
};
void start();

View File

@ -1,6 +1,7 @@
/* eslint-disable no-console */
const jetpack = require('fs-jetpack');
const qoa = require('qoa');
import jetpack from 'fs-jetpack';
// @ts-ignore no typings for qoa
import qoa from 'qoa';
qoa.config({
prefix: '>',
@ -72,6 +73,7 @@ async function start() {
const keys = Object.keys(defaultSettings);
for (const item of keys) {
// @ts-ignore no idea
envfile += `${item}=${defaultSettings[item]}\n`;
}
jetpack.write('.env', envfile);
@ -85,7 +87,8 @@ async function start() {
console.log('== MAKE SURE TO CHANGE IT AFTER YOUR FIRST LOGIN ==');
console.log('=====================================================');
console.log();
// eslint-disable-next-line @typescript-eslint/no-empty-function
setTimeout(() => {}, 1000);
}
start();
void start();

View File

@ -1,54 +0,0 @@
const nodePath = require('path');
const Knex = require('knex');
// eslint-disable-next-line func-names
Knex.QueryBuilder.extend('wasMutated', function() {
this.client.config.userParams.lastMutationTime = Date.now();
return this;
});
const db = Knex({
client: process.env.DB_CLIENT,
connection: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
filename: nodePath.join(__dirname, '../../../database/database.sqlite')
},
postProcessResponse: result => {
/*
Fun fact: Depending on the database used by the user and given that I don't want
to force a specific database for everyone because of the nature of this project,
some things like different data types for booleans need to be considered like in
the implementation below where sqlite returns 1 and 0 instead of true and false.
*/
const booleanFields = ['enabled', 'enableDownload', 'isAdmin', 'nsfw', 'generateZips', 'publicMode', 'userAccounts'];
const processResponse = row => {
Object.keys(row).forEach(key => {
if (booleanFields.includes(key)) {
if (row[key] === 0) row[key] = false;
else if (row[key] === 1) row[key] = true;
}
});
return row;
};
if (Array.isArray(result)) return result.map(row => processResponse(row));
if (typeof result === 'object') return processResponse(result);
return result;
},
useNullAsDefault: process.env.DB_CLIENT === 'sqlite3',
log: {
warn: msg => {
if (typeof msg === 'string' && msg.startsWith('.returning()')) return;
console.warn(msg);
}
},
userParams: {
lastMutationTime: null
}
});
module.exports = db;

View File

@ -1,77 +0,0 @@
const JWT = require('jsonwebtoken');
const db = require('./Database');
const moment = require('moment');
const log = require('../utils/Log');
const Util = require('../utils/Util');
class Route {
constructor(path, method, options) {
if (!path) throw new Error('Every route needs a URL associated with it.');
if (!method) throw new Error('Every route needs its method specified.');
this.path = path;
this.method = method;
this.options = options || {};
}
async authorize(req, res) {
const banned = await db
.table('bans')
.where({ ip: req.ip })
.first();
if (banned) return res.status(401).json({ message: 'This IP has been banned from using the service.' });
if (this.options.bypassAuth) return this.run(req, res, db);
// The only reason I call it token here and not Api Key is to be backwards compatible
// with the uploader and sharex
// Small price to pay.
if (req.headers.token) return this.authorizeApiKey(req, res, req.headers.token);
if (!req.headers.authorization) return res.status(401).json({ message: 'No authorization header provided' });
const token = req.headers.authorization.split(' ')[1];
if (!token) return res.status(401).json({ message: 'No authorization header provided' });
return JWT.verify(token, Util.config.secret, async (error, decoded) => {
if (error) {
log.error(error);
return res.status(401).json({ message: 'Invalid token' });
}
const id = decoded ? decoded.sub : '';
const iat = decoded ? decoded.iat : '';
const user = await db
.table('users')
.where({ id })
.first();
if (!user) return res.status(401).json({ message: 'Invalid authorization' });
if (iat && iat < moment(user.passwordEditedAt).format('x')) {
return res.status(401).json({ message: 'Token expired' });
}
if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' });
if (this.options.adminOnly && !user.isAdmin) { return res.status(401).json({ message: 'Invalid authorization' }); }
return this.run(req, res, db, user);
});
}
async authorizeApiKey(req, res, apiKey) {
if (!this.options.canApiKey) return res.status(401).json({ message: 'Api Key not allowed for this resource' });
const user = await db
.table('users')
.where({ apiKey })
.first();
if (!user) return res.status(401).json({ message: 'Invalid authorization' });
if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' });
return this.run(req, res, db, user);
}
run() {}
error(res, error) {
log.error(error);
return res.status(500).json({ message: 'There was a problem parsing the request' });
}
}
module.exports = Route;

View File

@ -1,123 +0,0 @@
require('dotenv').config();
if (!process.env.SERVER_PORT) {
console.log('Run the setup script first or fill the .env file manually before starting');
process.exit(0);
}
if (!process.env.DOMAIN) {
console.log('You failed to provide a domain for your instance. Edit the .env file manually and fix it.');
process.exit(0);
}
const { loadNuxt, build } = require('nuxt');
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const RateLimit = require('express-rate-limit');
const bodyParser = require('body-parser');
const jetpack = require('fs-jetpack');
const path = require('path');
const morgan = require('morgan');
const rfs = require('rotating-file-stream');
const CronJob = require('cron').CronJob;
const log = require('../utils/Log');
const Util = require('../utils/Util');
// eslint-disable-next-line no-unused-vars
const rateLimiter = new RateLimit({
windowMs: parseInt(Util.config.rateLimitWindow, 10),
max: parseInt(Util.config.rateLimitMax, 10),
delayMs: 0
});
class Server {
constructor() {
this.port = parseInt(process.env.SERVER_PORT, 10);
this.server = express();
this.server.set('trust proxy', 1);
this.server.use(helmet());
this.server.use(cors({ allowedHeaders: ['Accept', 'Authorization', 'Cache-Control', 'X-Requested-With', 'Content-Type', 'albumId', 'finishedChunks'] }));
this.server.use((req, res, next) => {
// This bypasses the headers.accept for album download, since it's accesed directly through the browser.
if ((req.url.includes('/api/album/') || req.url.includes('/zip')) && req.method === 'GET') return next();
// This bypasses the headers.accept if we are accessing the frontend
if (!req.url.includes('/api/') && req.method === 'GET') return next();
if (req.headers.accept && req.headers.accept.includes('application/vnd.chibisafe.json')) return next();
return res.status(405).json({ message: 'Incorrect `Accept` header provided' });
});
this.server.use(bodyParser.urlencoded({ extended: true }));
this.server.use(bodyParser.json());
if (process.env.NODE_ENV === 'production') {
const accessLogStream = rfs.createStream('access.log', {
interval: '1d', // rotate daily
path: path.join(__dirname, '../../../logs', 'log')
});
this.server.use(morgan('combined', { stream: accessLogStream }));
}
// Apply rate limiting to the api only
this.server.use('/api/', rateLimiter);
// Serve the uploads
this.server.use(express.static(path.join(__dirname, '../../../uploads')));
this.routesFolder = path.join(__dirname, '../routes');
// Save the cron job instances in case we want to stop them later
this.jobs = {};
}
registerAllTheRoutes() {
jetpack.find(this.routesFolder, { matching: '*.js' }).forEach(routeFile => {
const RouteClass = require(path.join('../../../', routeFile));
let routes = [RouteClass];
if (Array.isArray(RouteClass)) routes = RouteClass;
for (const File of routes) {
try {
const route = new File();
this.server[route.method](`/api${route.path}`, route.authorize.bind(route));
log.info(`Found route ${route.method.toUpperCase()} /api${route.path}`);
} catch (e) {
log.error(`Failed loading route from file ${routeFile} with error: ${e.message}`);
}
}
});
}
async serveNuxt() {
const isProd = process.env.NODE_ENV === 'production';
const nuxt = await loadNuxt(isProd ? 'start' : 'dev');
this.server.use(nuxt.render);
if (!isProd) {
build(nuxt);
}
}
createJobs() {
// TODO: move into the database config. (we can just show the crontab line for start, later on we can add dropdowns and stuff)
this.jobs.stats = new CronJob('0 0 * * * *', Util.saveStatsToDb, null, true);
}
start() {
jetpack.dir('uploads/chunks');
jetpack.dir('uploads/thumbs/square');
jetpack.dir('uploads/thumbs/preview');
this.registerAllTheRoutes();
this.serveNuxt();
const server = this.server.listen(this.port, () => {
log.success(`Backend ready and listening on port ${this.port}`);
});
server.setTimeout(600000);
this.createJobs();
}
}
const start = async () => {
const conf = await Util.config;
new Server().start();
};
start();

View File

@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default prisma;

View File

@ -0,0 +1,90 @@
import type { FastifyRequest } from 'fastify';
export interface RequestWithUser extends FastifyRequest {
user: {
id: number;
username: string;
isAdmin: boolean;
apiKey?: string | null | undefined;
};
}
export interface User {
id: number;
username: string;
password: string;
enabled: boolean;
isAdmin: boolean;
apiKey: string;
passwordEditedAt: string;
apiKeyEditedAt: string;
createdAt: string;
editedAt: string;
}
export interface File {
id: number;
userId?: number | null;
name: string;
original: string;
type: string;
size: number;
hash: string;
ip: string;
createdAt: Date;
editedAt: Date | null;
}
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: Date | null;
createdAt: Date;
editedAt: Date | null;
nsfw: boolean;
}
export interface Settings {
domain: string;
routePrefix: string;
rateLimitWindow: number;
rateLimitMax: number;
secret: string;
serviceName: string;
chunkSize: number;
maxSize: number;
generateZips: boolean;
generatedFilenameLength: number;
generatedAlbumLength: number;
blockedExtensions: string[];
publicMode: boolean;
userAccounts: boolean;
metaThemeColor: string;
metaDescription: string;
metaKeywords: string;
metaTwitterHandle: string;
backgroundImageURL: string;
logoURL: string;
statisticsCron: string;
enabledStatistics: string[];
savedStatistics: string[];
[key: string]: string | number | string[] | boolean;
}

View File

@ -1,54 +1,47 @@
import dotenv from 'dotenv/config';
import { NuxtConfig } from '@nuxt/types';
import { getConfig } from '../utils/Util';
import autoprefixer from 'autoprefixer';
const Util = require('./src/api/utils/Util');
export default async () => {
/*
FIXME:
Since Util.config is not populated during production env because it needs to grab the values from the db
we need to use this hack to populate it before we can access the properties without await like we do in the export below.
This will be solved once the TypeScript rewrite is complete as we can can simply pass a config object to express
and build from there, but for now the build needs to be triggered before the API is started.
*/
await Util.config;
return {
const settings = await getConfig();
const config: NuxtConfig = {
ssr: true,
srcDir: 'src/site/',
head: {
title: Util.config.serviceName,
titleTemplate: `%s | ${Util.config.serviceName}`,
title: settings.serviceName,
titleTemplate: `%s | ${settings.serviceName}`,
// TODO: Add the directory with pictures for favicon and stuff
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'theme-color', name: 'theme-color', content: `${Util.config.metaThemeColor}` },
{ hid: 'description', name: 'description', content: `${Util.config.metaDescription}` },
{ hid: 'keywords', name: 'keywords', content: `${Util.config.metaKeywords}` },
{ hid: 'theme-color', name: 'theme-color', content: `${settings.metaThemeColor}` },
{ hid: 'description', name: 'description', content: `${settings.metaDescription}` },
{ hid: 'keywords', name: 'keywords', content: `${settings.metaKeywords}` },
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: `${Util.config.serviceName}`
content: `${settings.serviceName}`
},
{ hid: 'application-name', name: 'application-name', content: `${Util.config.serviceName}` },
{ hid: 'application-name', name: 'application-name', content: `${settings.serviceName}` },
{ hid: 'twitter:card', name: 'twitter:card', content: 'summary' },
{ hid: 'twitter:site', name: 'twitter:site', content: `${Util.config.metaTwitterHandle}` },
{ hid: 'twitter:creator', name: 'twitter:creator', content: `${Util.config.metaTwitterHandle}` },
{ hid: 'twitter:title', name: 'twitter:title', content: `${Util.config.serviceName}` },
{ hid: 'twitter:description', name: 'twitter:description', content: `${Util.config.metaDescription}` },
{ hid: 'twitter:site', name: 'twitter:site', content: `${settings.metaTwitterHandle}` },
{ hid: 'twitter:creator', name: 'twitter:creator', content: `${settings.metaTwitterHandle}` },
{ hid: 'twitter:title', name: 'twitter:title', content: `${settings.serviceName}` },
{ hid: 'twitter:description', name: 'twitter:description', content: `${settings.metaDescription}` },
{ hid: 'twitter:image', name: 'twitter:image', content: `/logo.png` },
{ hid: 'og:url', property: 'og:url', content: `/` },
{ hid: 'og:type', property: 'og:type', content: 'website' },
{ hid: 'og:title', property: 'og:title', content: `${Util.config.serviceName}` },
{ hid: 'og:description', property: 'og:description', content: `${Util.config.metaDescription}` },
{ hid: 'og:title', property: 'og:title', content: `${settings.serviceName}` },
{ hid: 'og:description', property: 'og:description', content: `${settings.metaDescription}` },
{ hid: 'og:image', property: 'og:image', content: `/logo.png` },
{ hid: 'og:image:secure_url', property: 'og:image:secure_url', content: `/logo.png` },
{ hid: 'og:site_name', property: 'og:site_name', content: `${Util.config.serviceName}` }
{ hid: 'og:site_name', property: 'og:site_name', content: `${settings.serviceName}` }
],
link: [
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Nunito:300,400,600,700' },
// This one is a pain in the ass to make it customizable, so you should edit it manually
// @ts-ignore
{ type: 'application/json+oembed', href: `/oembed.json` }
]
},
@ -69,13 +62,14 @@ export default async () => {
linkExactActiveClass: 'is-active'
},
env: {
development: process.env.NODE_ENV !== 'production'
development: Boolean(process.env.NODE_ENV !== 'production').toString()
},
axios: {
baseURL: `${process.env.NODE_ENV === 'production' ? process.env.DOMAIN : 'http://localhost:5000'}/api`
baseURL: `${process.env.NODE_ENV === 'production' ? process.env.DOMAIN as string : 'http://localhost:5000'}/api`
},
build: {
extractCSS: process.env.NODE_ENV === 'production',
// @ts-ignore
postcss: {
preset: {
autoprefixer
@ -89,4 +83,5 @@ export default async () => {
}
}
};
return config;
};

View File

@ -0,0 +1,67 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import jetpack from 'fs-jetpack';
import path from 'path';
export default {
load: async (server: FastifyInstance) => {
/*
While in development we only want to match routes written in TypeScript but for production
we need to change it to javascript files since they will be compiled.
TODO: Would running the TypeScript files directly with something like ts-node be a good move?
*/
const matching = `*.${process.env.NODE_ENV === 'production' ? 'j' : 't'}s`;
for (const routeFile of await jetpack.findAsync(path.join(__dirname, '..', 'routes'), { matching })) {
try {
const slash = process.platform === 'win32' ? '\\' : '/';
const replace = process.env.NODE_ENV === 'production' ? `dist${slash}` : `src${slash}api${slash}`;
const route = await import(routeFile.replace(replace, `..${slash}`));
const paths: Array<string> = routeFile.split(slash);
const method = paths[paths.length - 1].split('.')[0];
// Get rid of the filename
paths.pop();
// Get rid of the src/api/routes part
paths.splice(0, 3);
let url: string = paths.join(slash);
// Transform path variables to express variables
url = url.replace('_', ':');
// Append the missing /
url = `/${url}`;
// Build final route
url = `${route.options?.ignoreRoutePrefix ? '' : '/api'}${url}`;
// Run middlewares if any, and in order of execution
const middlewares: any[] = [];
if (route.middlewares?.length) {
// Set default middlewares that need to be included
route.middlewares.unshift('ban');
// Load the middlewares defined in the route file
for (const middleware of route.middlewares) {
const importedMiddleware = await import(path.join(__dirname, '..', 'middlewares', middleware));
middlewares.push(importedMiddleware.default);
}
}
// Register the route in Fastify
server.route({
method: method.toUpperCase() as any,
url,
preHandler: middlewares,
handler: (req: FastifyRequest, res: FastifyReply) => route.run(req, res)
});
server.log.info(`Found route ${method.toUpperCase()} ${url}`);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
server.log.error(`${routeFile} :: ERROR`);
server.log.error(error);
}
}
}
};

View File

@ -1,7 +1,4 @@
require('dotenv').config();
const Joi = require('joi');
const { env } = process;
import Joi from 'joi';
const StatsGenerator = require('../utils/StatsGenerator');

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: Record<string, { value: { total: number; used: number }; type: string }> = {};
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;

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

@ -0,0 +1,401 @@
import jetpack from 'fs-jetpack';
import randomstring from 'randomstring';
import path from 'path';
import prisma from '../structures/database';
import { utc } 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, Settings } 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;
/*
TODO: Ask crawl how to properly type this.
I want that if I call getConfig() to know the properties that will come back and their types
to use them in nuxt.ts for example
*/
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>) as Settings;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
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']
} as Settings);
export const wipeConfigDb = async () => {
try {
await prisma.settings.deleteMany();
} catch (error) {
console.error(error);
}
};
export const writeConfigToDb = async (config: { key: string; value: string | number | string[] | boolean }) => {
if (!config.key) return;
try {
const data = {
key: config.key,
value: JSON.stringify(config.value)
};
await prisma.settings.create({
data
});
} 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 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 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: ExtendedFile = { ...file };
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 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 = 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 = 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 = 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();

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"moduleResolution": "Node",
"esModuleInterop": true,
"sourceMap": true,
"strict": true,
"target": "esnext",
"module": "commonjs",
"jsx": "preserve",
"lib": ["esnext", "dom"],
"rootDir": "./src/api",
"outDir": "./dist",
"sourceRoot": "./",
"types": [
"@types/node",
"@nuxt/types"
]
},
"include": ["**/*.ts", "**/*.d.ts"]
}