diff --git a/.eslintrc.json b/.eslintrc.json index 03d9e9a..5f52d4f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -32,6 +32,10 @@ "comma-dangle": [ "warn", "always-multiline" + ], + "new-parens": [ + "warn", + "never" ] } } \ No newline at end of file diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..9d55039 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,136 @@ +import { Response } from "express"; + +export abstract class SharXError { + code: string; + data: unknown; + httpCode: number; + + constructor(data: unknown, httpCode: number, code?: string) { + this.code = code || this.constructor.name; + this.data = data; + this.httpCode = httpCode; + } + + toJSON(): Record { + return { + code: this.code, + data: this.data, + }; + } + + send(res: Response): void { + res.status(this.httpCode).json(this.toJSON()); + } +} + +export class UnknownError extends SharXError { + constructor(data: unknown) { + super(data, 500); + } +} + +export class MalformedRequestError extends SharXError { + constructor(data: Record = {}) { + super(data, 400); + } +} + +interface JsonFieldErrorData extends Record { + field: string; +} + +export class JsonFieldError extends MalformedRequestError { + constructor(data: JsonFieldErrorData) { + super(data); + } +} + +interface IllegalCharacterErrorData extends JsonFieldErrorData { + character: string; +} + +export class IllegalCharacterError extends JsonFieldError { + constructor(data: IllegalCharacterErrorData) { + super(data); + } +} + +interface TooShortFieldErrorData extends JsonFieldErrorData { + minLength: number; +} + +export class TooShortFieldError extends JsonFieldError { + constructor(data: TooShortFieldErrorData) { + super(data); + } +} + +interface TooLongFieldErrorData extends JsonFieldErrorData { + maxLength: number; +} + +export class TooLongFieldError extends JsonFieldError { + constructor(data: TooLongFieldErrorData) { + super(data); + } +} + +export class InvalidAuthHeaderError extends MalformedRequestError { + constructor(data: Record = {}) { + super(data); + } +} + +export class InvalidCredentialsError extends SharXError { + constructor(data: Record = {}) { + super(data, 401); + } +} + +export class InvalidTokenError extends InvalidCredentialsError { + constructor(data: Record = {}) { + super(data); + } +} + +export class ExpiredTokenError extends InvalidTokenError { + constructor(data: Record = {}) { + super(data); + this.httpCode = 403; + } +} + +export class ResourceNotFoundError extends SharXError { + constructor(data: Record = {}) { + super(data, 404); + } +} + +export class ImageNotFoundError extends ResourceNotFoundError { + constructor(data: Record = {}) { + super(data); + } +} + +export class ResourceAlreadyExistsError extends SharXError { + constructor(data: Record = {}) { + super(data, 400); + } +} + +interface FieldAlreadyExistsErrorData extends Record { + field: string; +} + +export class FieldAlreadyExistsError extends ResourceAlreadyExistsError { + constructor(data: FieldAlreadyExistsErrorData) { + super(data); + } +} + +export class UserAlreadyRegisteredError extends ResourceAlreadyExistsError { + constructor(data: FieldAlreadyExistsErrorData) { + super(data); + } +} + diff --git a/src/index.ts b/src/index.ts index 5731d6f..0dcb549 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,7 +68,7 @@ routes.forEach((route) => { }); app.listen(port, () => { - startTime = new Date(); + startTime = new Date; signale.success(`Listening on port ${port}`); }); diff --git a/src/routes/authRouter.ts b/src/routes/authRouter.ts index 8fe876e..44d19e3 100644 --- a/src/routes/authRouter.ts +++ b/src/routes/authRouter.ts @@ -1,9 +1,10 @@ import { NextFunction, Request, Response, Router } from "express"; import { prisma } from "../index"; -import { createSignale } from "../utils"; +import { createSignale, wrapper } from "../utils"; import { randomBytes, createHmac } from "crypto"; import jwt, { TokenExpiredError } from "jsonwebtoken"; import { AuthJWT, Prisma, User } from "@prisma/client"; +import { ExpiredTokenError, IllegalCharacterError, InvalidAuthHeaderError, InvalidCredentialsError, InvalidTokenError, TooLongFieldError, TooShortFieldError, UserAlreadyRegisteredError } from "../errors"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const signale = createSignale(__filename); @@ -11,47 +12,45 @@ const signale = createSignale(__filename); const router = Router(); export function authenticateJWT(req: Request, res: Response, next: NextFunction) { - const tokenHeader = req.headers["authorization"]; - if (!tokenHeader) return res.status(401).json({ success: false, error: "No token provided" }); - const token = tokenHeader.split(" ")[1]; - if (!token) return res.status(401).json({ success: false, error: "No token provided" }); - - try { - const decoded = jwt.verify(token, process.env.JWT_SECRET as string); - if (!(decoded instanceof Object) || - !(typeof decoded.user === "string") || - !(typeof decoded.type === "string") || - !(decoded.type === "auth")) - return res.status(401).json({ success: false, error: "Invalid token" }); - prisma.authJWT.findUnique({ - where: { - token: token, - }, - include: { - user: true, - }, - }).then(tokenObj => { + wrapper(res, async () => { + const tokenHeader = req.headers["authorization"]; + if (!tokenHeader) throw new InvalidAuthHeaderError; + const token = tokenHeader.split(" ")[1]; + if (!token) throw new InvalidAuthHeaderError; + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET as string); + if (!(decoded instanceof Object) || + !(typeof decoded.user === "string") || + !(typeof decoded.type === "string") || + !(decoded.type === "auth")) + throw new InvalidTokenError; + const tokenObj = await prisma.authJWT.findUnique({ + where: { + token: token, + }, + include: { + user: true, + }, + }); if (!tokenObj) { - return res.status(403).json({ success: false, error: "Invalid token" }); + throw new InvalidTokenError; } res.locals.user = tokenObj.user.uuid; res.locals.userObj = tokenObj.user; res.locals.jwt = tokenObj; next(); - }).catch((e: unknown) => { - res.status(500).json({ success: false, error: e }); - }); - } - catch (err) { - if (typeof err === typeof TokenExpiredError) { - return res.status(403).json({ success: false, error: "Token expired" }); } - else { - return res.status(401).json({ success: false, error: "Invalid token" }); + catch (err) { + if (typeof err === typeof TokenExpiredError) { + throw new ExpiredTokenError; + } + else { + throw new InvalidTokenError; + } } - } - + }); } interface CreateUserBody { @@ -60,16 +59,14 @@ interface CreateUserBody { email: string; } -function checkUsername(username: string): boolean | string { - if (username.length < 3) return "Too short! Minimum 3 characters"; - if (username.length > 16) return "Too long! Maximum 16 characters"; +function checkUsername(username: string) { + if (username.length < 3) throw new TooShortFieldError({ field: "username", minLength: 3 }); + if (username.length > 16) throw new TooLongFieldError({ field: "username", maxLength: 16 }); for (const c of username) { if (!([..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"].includes(c))) - return `Invalid character: ${c}`; + throw new IllegalCharacterError({ field: "username", character: c }); } - - return true; } function hashPassword(password: string, salt?: string): { hash: string, salt: string } { @@ -87,7 +84,7 @@ function hashPassword(password: string, salt?: string): { hash: string, salt: st } async function genJWT(user: string, ip: string) { - const token = jwt.sign({ user: user, ip: ip, type: "auth" }, process.env.JWT_SECRET as string, { expiresIn: "14d" }); + const token = jwt.sign({ user: user, ip: ip, type: "auth", rnd: randomBytes(8).toString("hex") }, process.env.JWT_SECRET as string, { expiresIn: "14d" }); await prisma.authJWT.create({ data: { userId: user, @@ -97,14 +94,13 @@ async function genJWT(user: string, ip: string) { return token; } -router.post("/create", async (req, res) => { - try { +router.post("/create", (req, res) => { + wrapper(res, async () => { const { username, password, email } = req.body as CreateUserBody; - const usernameError = checkUsername(username); - if (usernameError !== true) throw usernameError; - if (password.length == 0) throw "Please specify a password"; - if (email.length == 0) throw "Please specify an email"; + checkUsername(username); + if (password.length < 5) throw new TooShortFieldError({ field: "password", minLength: 5 }); + if (email.length < 1) throw new TooShortFieldError({ field: "email", minLength: 1 }); const { hash, salt } = hashPassword(password); @@ -120,25 +116,22 @@ router.post("/create", async (req, res) => { const token = await genJWT(user.uuid, req.ip); - res.json({ - success: true, + return { uuid: user.uuid, token: token, - }); + }; } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError) { - if (err.code === "P2002") throw "User already exists"; + if (err.code === "P2002") { + throw new UserAlreadyRegisteredError({ + field: (err.meta?.target as string[])[0], + }); + } } throw err; } - } - catch (err) { - res.json({ - success: false, - error: err, - }); - } + }); }); interface LoginBody { @@ -146,8 +139,8 @@ interface LoginBody { password: string; } -router.post("/login", async (req, res) => { - try { +router.post("/login", (req, res) => { + wrapper(res, async () => { const { password, email } = req.body as LoginBody; const user = await prisma.user.findUnique({ @@ -156,63 +149,51 @@ router.post("/login", async (req, res) => { }, }); - if (!user) throw "Invalid credentials"; + if (!user) throw new InvalidCredentialsError; const { hash } = hashPassword(password, user.passwordSalt); - if (hash !== user.passwordHash) throw "Invalid credentials"; + if (hash !== user.passwordHash) throw new InvalidCredentialsError; const token = await genJWT(user.uuid, req.ip); - res.json({ - success: true, + return { uuid: user.uuid, token: token, - }); - } - catch (err) { - res.json({ - success: false, - error: err, - }); - } + }; + }); }); -router.delete("/logout", authenticateJWT, async (req, res) => { - try { +router.delete("/logout", authenticateJWT, (req, res) => { + wrapper(res, async () => { await prisma.authJWT.delete({ where: { token: (res.locals.jwt as AuthJWT).token, }, }); - res.json({ - success: true, - }); - } - catch (err) { - res.json({ - success: false, - error: err, - }); - } + return {}; + }); }); interface ChangePasswordBody { oldPassword: string; password: string; } -router.post("/changePassword", authenticateJWT, async (req, res) => { - try { +router.post("/changePassword", authenticateJWT, (req, res) => { + wrapper(res, async () => { const { oldPassword, password } = req.body as ChangePasswordBody; - if (password.length == 0) throw "Please specify a password"; + if (password.length < 5) throw new TooShortFieldError({ + field: "password", + minLength: 5, + }); const user = res.locals.userObj as User; const { hash: oldHash } = hashPassword(oldPassword, user.passwordSalt); - if (oldHash !== user.passwordHash) throw "Invalid credentials"; + if (oldHash !== user.passwordHash) throw new InvalidCredentialsError; const { hash, salt } = hashPassword(password); @@ -232,16 +213,8 @@ router.post("/changePassword", authenticateJWT, async (req, res) => { }, }); - res.json({ - success: true, - }); - } - catch (err) { - res.json({ - success: false, - error: err, - }); - } + return {}; + }); }); export const prefix = "/auth"; diff --git a/src/routes/imageRouter.ts b/src/routes/imageRouter.ts index 3faa502..dbe77e1 100644 --- a/src/routes/imageRouter.ts +++ b/src/routes/imageRouter.ts @@ -1,12 +1,14 @@ import { Router } from "express"; -import { createSignale, imageHashAsync } from "../utils"; +import { createSignale, imageHashAsync, wrapper } from "../utils"; import { prisma } from "../index"; import { randomInt } from "crypto"; import multer from "multer"; import { existsSync, mkdirSync } from "fs"; import { writeFile } from "fs/promises"; import { extname, join, resolve } from "path"; -import jwt, { TokenExpiredError } from "jsonwebtoken"; +import jwt, { JsonWebTokenError, TokenExpiredError } from "jsonwebtoken"; +import { ExpiredTokenError, ImageNotFoundError, InvalidAuthHeaderError, InvalidTokenError, MalformedRequestError, ResourceAlreadyExistsError } from "../errors"; +import { Prisma } from "@prisma/client"; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -23,12 +25,12 @@ if (!existsSync(imageStorageDir)) { mkdirSync(absoluteImageStorageDir, { recursive: true }); } -router.post("/upload", multer().single("image"), async (req, res) => { - try { +router.post("/upload", multer().single("image"), (req, res) => { + wrapper(res, async () => { const tokenHeader = req.headers["authorization"]; - if (!tokenHeader) return res.status(401).json({ success: false, error: "No provided" }); + if (!tokenHeader) throw new InvalidAuthHeaderError; const token = tokenHeader.split(" ")[1]; - if (!token) return res.status(401).json({ success: false, error: "No token provided" }); + if (!token) throw new InvalidAuthHeaderError; try { const decoded = jwt.verify(token, process.env.JWT_SECRET as string); @@ -36,17 +38,22 @@ router.post("/upload", multer().single("image"), async (req, res) => { !(typeof decoded.user === "string") || !(typeof decoded.type === "string") || !(decoded.type === "upload")) - return res.status(401).json({ success: false, error: "Invalid token" }); - const keyObject = await prisma.uploadKey.findUnique({ where: { key: token } }); - if (!keyObject) return res.status(401).json({ success: false, error: "Invalid token" }); - const user = await prisma.user.findUnique({ where: { uuid: decoded.user } }); - if (!user) { - return res.status(401).json({ success: false, error: "Invalid user" }); + throw new InvalidTokenError; + const keyObject = await prisma.uploadKey.findUnique({ + where: { + key: token, + }, + include: { + user: true, + }, + }); + if (!keyObject) { + throw new InvalidTokenError; } const image = req.file; - if (!image) throw "No image provided"; + if (!image) throw new MalformedRequestError({ field: "image" }); const imageHash = await imageHashAsync({ data: image.buffer }, 16, true); let shortImageId = ""; @@ -60,38 +67,39 @@ router.post("/upload", multer().single("image"), async (req, res) => { name: image.originalname, hash: imageHash, size: image.size, - userId: user.uuid, + userId: keyObject.user.uuid, }, }); await writeFile(join(absoluteImageStorageDir, `${dbImage.uuid}${extname(image.originalname)}`), image.buffer); - res.json({ - success: true, + return { shortid: shortImageId, uuid: dbImage.uuid, - }); + }; } catch (err) { if (typeof err === typeof TokenExpiredError) { - return res.status(403).json({ success: false, error: "Token expired" }); + throw new ExpiredTokenError; + } + else if (typeof err === typeof JsonWebTokenError) { + throw new InvalidTokenError; + } + else if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === "P2002") { + // TODO: Return the existing image's url OR create a new object using the old filename + throw new ResourceAlreadyExistsError; + } } else { - return res.status(401).json({ success: false, error: "Invalid token" }); + throw err; } } - - } - catch (err) { - res.status(500).json({ - success: false, - error: err, - }); - } + }); }); -router.get("/:id", async (req, res) => { - try { +router.get("/:id", (req, res) => { + wrapper(res, async () => { const imgId = req.params.id; const dbImage = await prisma.image.findUnique({ @@ -100,25 +108,19 @@ router.get("/:id", async (req, res) => { }, }); - if (!dbImage) throw "Image not found"; + if (!dbImage) throw new ImageNotFoundError; const imgPath = `${dbImage.uuid}${extname(dbImage.name)}`; - if (!existsSync(join(absoluteImageStorageDir, imgPath))) throw "Image file not found"; + if (!existsSync(join(absoluteImageStorageDir, imgPath))) throw new ImageNotFoundError; res.sendFile(imgPath, { root: absoluteImageStorageDir }); - } - catch (err) { - res.status(500).json({ - success: false, - error: err, - }); - } + }); }); -router.get("/:id/meta", async (req, res) => { - try { +router.get("/:id/meta", (req, res) => { + wrapper(res, async () => { const imgId = req.params.id; const dbImage = await prisma.image.findUnique({ @@ -127,24 +129,17 @@ router.get("/:id/meta", async (req, res) => { }, }); - if (!dbImage) throw "Image not found"; + if (!dbImage) throw new ImageNotFoundError; - res.json({ - success: true, + return { shortid: dbImage.shortid, uuid: dbImage.uuid, name: dbImage.name, uploaded: dbImage.uploaded, size: dbImage.size, hash: dbImage.hash, - }); - } - catch (err) { - res.status(500).json({ - success: false, - error: err, - }); - } + }; + }); }); export const prefix = "/image"; diff --git a/src/routes/mainRouter.ts b/src/routes/mainRouter.ts index 0bebabb..b3a643a 100644 --- a/src/routes/mainRouter.ts +++ b/src/routes/mainRouter.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { createSignale } from "../utils"; +import { createSignale, wrapper } from "../utils"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const signale = createSignale(__filename); @@ -7,9 +7,10 @@ const signale = createSignale(__filename); const router = Router(); router.get("/", (req, res) => { - res.json({ - success: true, - message: "Hello World!", + wrapper(res, () => { + return { + message: "Hello World!", + }; }); }); diff --git a/src/routes/serviceRouter.ts b/src/routes/serviceRouter.ts index 52b5f88..e315204 100644 --- a/src/routes/serviceRouter.ts +++ b/src/routes/serviceRouter.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { createSignale, getOutput } from "../utils"; +import { createSignale, getOutput, wrapper } from "../utils"; import { prisma, startTime } from "../index"; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -7,11 +7,10 @@ const signale = createSignale(__filename); const router = Router(); -router.get("/info", async (req, res) => { - try { +router.get("/info", (req, res) => { + wrapper(res, async () => { const tag = await getOutput("git describe --tags"); - res.json({ - success: true, + return { git: { commit: await getOutput("git rev-parse HEAD"), tag: tag, @@ -19,21 +18,16 @@ router.get("/info", async (req, res) => { semver: tag.substring(1).split("-")[0].split(".").map(x => parseInt(x)), }, name: process.env.CUSTOM_HOST_NAME || "SharX", - }); - } - catch (err) { - res.status(500).json({ - success: false, - error: err, - }); - } + }; + }); }); -router.get("/stats", async (req, res) => { - res.json({ - success: true, - uptime: Math.floor(Date.now() / 1000) - Math.floor(startTime.getTime() / 1000), - images: await prisma.image.count(), +router.get("/stats", (req, res) => { + wrapper(res, async () => { + return { + uptime: Math.floor(Date.now() / 1000) - Math.floor(startTime.getTime() / 1000), + images: await prisma.image.count(), + }; }); }); diff --git a/src/routes/userRouter.ts b/src/routes/userRouter.ts index 4a6b02f..5051d0a 100644 --- a/src/routes/userRouter.ts +++ b/src/routes/userRouter.ts @@ -1,18 +1,20 @@ import { Router } from "express"; import { prisma } from "../index"; -import { createSignale } from "../utils"; +import { createSignale, wrapper } from "../utils"; import jwt from "jsonwebtoken"; import { authenticateJWT } from "./authRouter"; import { User } from "@prisma/client"; +import { randomBytes } from "crypto"; +import { ResourceNotFoundError } from "../errors"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const signale = createSignale(__filename); const router = Router(); -router.post("/uploadKey", authenticateJWT, async (req, res) => { - try { - const key = jwt.sign({ user: res.locals.user as string, ip: req.ip, type: "upload" }, process.env.JWT_SECRET as string, { expiresIn: "30d" }); +router.post("/uploadKey", authenticateJWT, (req, res) => { + wrapper(res, async () => { + const key = jwt.sign({ user: res.locals.user as string, ip: req.ip, type: "upload", rnd: randomBytes(8).toString("hex") }, process.env.JWT_SECRET as string, { expiresIn: "30d" }); await prisma.uploadKey.create({ data: { @@ -21,21 +23,12 @@ router.post("/uploadKey", authenticateJWT, async (req, res) => { }, }); - res.json({ - success: true, - key: key, - }); - } - catch (err) { - res.json({ - success: false, - error: err, - }); - } + return { key: key }; + }); }); -router.get("/uploadKey", authenticateJWT, async (req, res) => { - try { +router.get("/uploadKey", authenticateJWT, (req, res) => { + wrapper(res, async () => { const keyObjects = await prisma.uploadKey.findMany({ where: { userId: res.locals.user as string, @@ -47,28 +40,21 @@ router.get("/uploadKey", authenticateJWT, async (req, res) => { keys.push(keyObject.key); } - res.json({ - success: true, - keys: keys, - }); - } - catch (err) { - res.json({ - success: false, - error: err, - }); - } + return { keys: keys }; + }); }); -router.delete("/uploadKey/:key", authenticateJWT, async (req, res) => { - try { +router.delete("/uploadKey/:key", authenticateJWT, (req, res) => { + wrapper(res, async () => { const keyObject = await prisma.uploadKey.findFirst({ where: { key: req.params.key, userId: res.locals.user as string, }, }); - if (!keyObject) throw "Key not found"; + if (!keyObject) { + new ResourceNotFoundError({ resource: "uploadKey" }).send(res); + }; await prisma.uploadKey.delete({ where: { @@ -76,26 +62,19 @@ router.delete("/uploadKey/:key", authenticateJWT, async (req, res) => { }, }); - res.json({ - success: true, - }); - } - catch (err) { - res.json({ - success: false, - error: err, - }); - } + return {}; + }); }); router.get("/info", authenticateJWT, (req, res) => { - const user = res.locals.userObj as User; - res.json({ - success: true, - uuid: res.locals.user as string, - username: user.username, - email: user.email, - createdAt: user.created.getTime(), + wrapper(res, () => { + const user = res.locals.userObj as User; + return { + uuid: res.locals.user as string, + username: user.username, + email: user.email, + createdAt: user.created.getTime(), + }; }); }); diff --git a/src/utils.ts b/src/utils.ts index ec10435..25ab9f4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,8 @@ import { basename } from "path"; import Signale from "signale"; import { exec } from "child_process"; import { promisify } from "util"; +import { Response } from "express"; +import { SharXError, UnknownError } from "./errors"; export function createSignale(filename: string, notify = true) { const signale = Signale.scope(basename(filename)); @@ -26,3 +28,34 @@ export async function getOutput(command: string) { const { stdout } = await asyncExec(command); return stdout.trim(); } + +export function success(res: Response, data: Record) { + return res.status(200).json({ success: true, ...data }); +} + +function handleError(err: unknown, res: Response) { + if (err instanceof SharXError) { + err.send(res); + } + else { + new UnknownError(err).send(res); + } +} + +export function wrapper(res: Response, cb: (() => void | Record | Promise>)) { + try { + const result = cb(); + if (result instanceof Promise) { + result.then(ret => { + if (ret) + success(res, ret); + }).catch(err => handleError(err, res)); + } + else if (result) { + success(res, result); + } + } + catch (err) { + handleError(err, res); + } +}