diff --git a/package-lock.json b/package-lock.json index c882d2e..c21765a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "multer": "^1.4.5-lts.1", "node-fetch": "^3.3.2", "nodemailer": "^6.9.3", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "zod": "^3.23.5" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^2.4.6", @@ -1941,6 +1942,14 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/zod": { + "version": "3.23.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.5.tgz", + "integrity": "sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 15f91f9..af8bc38 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "multer": "^1.4.5-lts.1", "node-fetch": "^3.3.2", "nodemailer": "^6.9.3", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "zod": "^3.23.5" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^2.4.6", diff --git a/src/server/lib/files.ts b/src/server/lib/files.ts index 0145e78..92b0d1b 100644 --- a/src/server/lib/files.ts +++ b/src/server/lib/files.ts @@ -8,22 +8,23 @@ import config, { Configuration } from "./config.js" import "dotenv/config" import * as Accounts from "./accounts.js" +import { z } from "zod" +import * as schemas from "./schemas/files.js" -export let id_check_regex = /[A-Za-z0-9_\-\.\!\=\:\&\$\,\+\;\@\~\*\(\)\']+/ export let alphanum = Array.from( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" ) // bad solution but whatever -export type FileVisibility = "public" | "anonymous" | "private" +export type FileVisibility = z.infer /** * @description Generates an alphanumeric string, used for files * @param length Length of the ID * @returns a random alphanumeric string */ -export function generateFileId(length: number = 5) { +export function generateFileId(length: number = 5): z.infer { let fid = "" for (let i = 0; i < length; i++) { fid += alphanum[crypto.randomInt(0, alphanum.length)] @@ -31,35 +32,7 @@ export function generateFileId(length: number = 5) { return fid } -/** - * @description Assert multiple conditions... this exists out of pure laziness - * @param conditions - */ - -function multiAssert( - conditions: Map -) { - for (let [cond, err] of conditions.entries()) { - if (cond) return err - } -} - -export type FileUploadSettings = Partial> & - Pick & { uploadId?: string } - -export interface FilePointer { - filename: string - mime: string - messageids: string[] - owner?: string - sizeInBytes?: number - tag?: string - visibility?: FileVisibility - reserved?: boolean - chunkSize?: number - lastModified?: number - md5?: string -} +export type FilePointer = z.infer export interface StatusCodeError { status: number @@ -470,9 +443,9 @@ export class UploadStream extends Writable { sizeInBytes: this.filled, visibility: ogf ? ogf.visibility - : this.owner - ? Accounts.getFromId(this.owner)?.defaultFileVisibility - : undefined, + : this.owner + && Accounts.getFromId(this.owner)?.defaultFileVisibility + || "public", // so that json.stringify doesnt include tag:undefined ...((ogf || {}).tag ? { tag: ogf.tag } : {}), @@ -527,12 +500,11 @@ export class UploadStream extends Writable { return this.destroy( new WebError(400, "duplicate attempt to set upload ID") ) - if ( - !id || - id.match(id_check_regex)?.[0] != id || - id.length > this.files.config.maxUploadIdLength - ) - return this.destroy(new WebError(400, "invalid file ID")) + + let check = schemas.FileId.safeParse(id); + + if (!check.success) + return this.destroy(new WebError(400, check.error.message)) if (this.files.files[id] && this.files.files[id].owner != this.owner) return this.destroy(new WebError(403, "you don't own this file")) @@ -717,4 +689,4 @@ export default class Files { throw err }) } -} +} \ No newline at end of file diff --git a/src/server/lib/middleware.ts b/src/server/lib/middleware.ts index a7a9cba..61e58da 100644 --- a/src/server/lib/middleware.ts +++ b/src/server/lib/middleware.ts @@ -3,6 +3,7 @@ import type { Context, Handler as RequestHandler } from "hono" import ServeError from "../lib/errors.js" import * as auth from "./auth.js" import { setCookie } from "hono/cookie" +import { ZodObject } from "zod" /** * @description Middleware which adds an account, if any, to ctx.get("account") @@ -102,20 +103,10 @@ export const login = (ctx: Context, account: string) => setCookie(ctx, "auth", a httpOnly: true }) -type SchemeType = "array" | "object" | "string" | "number" | "boolean" - -interface SchemeObject { - type: "object" - children: { - [key: string]: SchemeParameter +export const scheme = function(scheme: ZodObject): RequestHandler { + return function(ctx, next) { + let chk = scheme.safeParse(ctx.req.json()) + if (chk.success) next() + else ServeError(ctx, 400, chk.error.message) } -} - -interface SchemeArray { - type: "array" - children: - | SchemeParameter /* All children of the array must be this type */ - | SchemeParameter[] /* Array must match this pattern */ -} - -type SchemeParameter = SchemeType | SchemeObject | SchemeArray +} \ No newline at end of file diff --git a/src/server/lib/schemas/accounts.ts b/src/server/lib/schemas/accounts.ts new file mode 100644 index 0000000..4d85cc8 --- /dev/null +++ b/src/server/lib/schemas/accounts.ts @@ -0,0 +1,21 @@ +import {z} from "zod" +import { FileVisibility } from "./files.js" + +export const StringPassword = z.string().min(8,"password must be at least 8 characters") +export const Password = + z.object({ + hash: z.string(), + salt: z.string() + }) +export const Username = + z.string().min(3, "username too short").max(20, "username too long").regex(/[A-Za-z0-9_\-\.]+/, "username contains invalid characters") +export const Account = + z.object({ + id: z.string(), + username: Username, + email: z.optional(z.string().email("must be an email")), + password: Password, + files: z.array(z.string()), + admin: z.boolean(), + defaultFileVisibility: FileVisibility + }) \ No newline at end of file diff --git a/src/server/lib/schemas/files.ts b/src/server/lib/schemas/files.ts new file mode 100644 index 0000000..cd23dcf --- /dev/null +++ b/src/server/lib/schemas/files.ts @@ -0,0 +1,18 @@ +import {z} from "zod" +import config from "../config.js" + +export const FileId = z.string().regex(/[A-Za-z0-9_\-\.\!\=\:\&\$\,\+\;\@\~\*\(\)\']+/).max(config.maxUploadIdLength) +export const FileVisibility = z.enum(["public", "anonymous", "private"]) +export const FileTag = z.string().toLowerCase().max(30, "tag length too long") +export const FilePointer = z.object({ + filename: z.string().max(256, "filename too long"), + mime: z.string().max(256, "mimetype too long"), + messageids: z.array(z.string()), + owner: z.optional(z.string()), + sizeInBytes: z.optional(z.number()), + tag: z.optional(FileTag), + visibility: z.optional(FileVisibility).default("public"), + chunkSize: z.optional(z.number()), + lastModified: z.optional(z.number()), + md5: z.optional(z.string()) +}) \ No newline at end of file diff --git a/src/server/lib/schemas/index.ts b/src/server/lib/schemas/index.ts new file mode 100644 index 0000000..4d030ab --- /dev/null +++ b/src/server/lib/schemas/index.ts @@ -0,0 +1,2 @@ +export * as AccountSchemas from "./accounts.js" +export * as FileSchemas from "./files.js" \ No newline at end of file diff --git a/src/server/routes/api/v0/authRoutes.ts b/src/server/routes/api/v0/authRoutes.ts index 1017d4c..cb09122 100644 --- a/src/server/routes/api/v0/authRoutes.ts +++ b/src/server/routes/api/v0/authRoutes.ts @@ -14,8 +14,7 @@ import config from "../../../lib/config.js" import ServeError from "../../../lib/errors.js" import Files, { FileVisibility, - generateFileId, - id_check_regex, + generateFileId } from "../../../lib/files.js" import { writeFile } from "fs/promises" diff --git a/src/server/routes/api/v0/fileApiRoutes.ts b/src/server/routes/api/v0/fileApiRoutes.ts index 9b59fff..434b817 100644 --- a/src/server/routes/api/v0/fileApiRoutes.ts +++ b/src/server/routes/api/v0/fileApiRoutes.ts @@ -68,10 +68,6 @@ export default function (files: Files) { let fp = files.files[e] - if (fp.reserved) { - return - } - switch (body.action) { case "delete": files.unlink(e, true) diff --git a/src/server/routes/api/v1/account.ts b/src/server/routes/api/v1/account.ts index ab6a41d..08c4ecc 100644 --- a/src/server/routes/api/v1/account.ts +++ b/src/server/routes/api/v1/account.ts @@ -5,7 +5,7 @@ import { getCookie, setCookie } from "hono/cookie" // Libs -import Files, { id_check_regex } from "../../../lib/files.js" +import Files from "../../../lib/files.js" import * as Accounts from "../../../lib/accounts.js" import * as auth from "../../../lib/auth.js" import { @@ -15,11 +15,14 @@ import { noAPIAccess, requiresAccount, requiresPermissions, + scheme, } from "../../../lib/middleware.js" import ServeError from "../../../lib/errors.js" import { CodeMgr, sendMail } from "../../../lib/mail.js" import Configuration from "../../../lib/config.js" +import { AccountSchemas, FileSchemas } from "../../../lib/schemas/index.js" +import { z } from "zod" const router = new Hono<{ Variables: { @@ -80,13 +83,7 @@ const validators: { | ValidatorWithSettings } = { defaultFileVisibility(actor, target, params) { - if ( - ["public", "private", "anonymous"].includes( - params.defaultFileVisibility - ) - ) - return params.defaultFileVisibility - else return [400, "invalid file visibility"] + return params.defaultFileVisibility }, email: { acceptsNull: true, @@ -109,8 +106,8 @@ const validators: { return undefined } - if (typeof params.email !== "string") - return [400, "email must be string"] + if (!z.string().email().safeParse(typeof params.email).success) + return [400, "bad email"] if (actor.admin) return params.email // send verification email @@ -150,9 +147,6 @@ const validators: { ) return [401, "current password incorrect"] - if (typeof params.password != "string" || params.password.length < 8) - return [400, "password must be 8 characters or longer"] - if (target.email) { sendMail( target.email, @@ -173,25 +167,9 @@ const validators: { ) return [401, "current password incorrect"] - if ( - typeof params.username != "string" || - params.username.length < 3 || - params.username.length > 20 - ) - return [ - 400, - "username must be between 3 and 20 characters in length", - ] - if (Accounts.getFromUsername(params.username)) return [400, "account with this username already exists"] - if ( - (params.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] != - params.username - ) - return [400, "username has invalid characters"] - if (target.email) { sendMail( target.email, @@ -207,12 +185,7 @@ const validators: { customCSS: { acceptsNull: true, validator: (actor, target, params) => { - if ( - !params.customCSS || - (params.customCSS.match(id_check_regex)?.[0] == - params.customCSS && - params.customCSS.length <= Configuration.maxUploadIdLength) - ) + if (FileSchemas.FileId.safeParse(params.customCSS).success) return params.customCSS else return [400, "bad file id"] }, @@ -272,7 +245,10 @@ function isMessage(object: any): object is Message { } export default function (files: Files) { - router.post("/", async (ctx) => { + router.post("/", scheme(z.object({ + username: AccountSchemas.Username, + password: AccountSchemas.StringPassword + })), async (ctx) => { const body = await ctx.req.json() if (!Configuration.accounts.registrationEnabled) { return ServeError(ctx, 403, "account registration disabled") @@ -290,28 +266,6 @@ export default function (files: Files) { ) } - if (body.username.length < 3 || body.username.length > 20) { - return ServeError( - ctx, - 400, - "username must be over or equal to 3 characters or under or equal to 20 characters in length" - ) - } - - if ( - (body.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] != body.username - ) { - return ServeError(ctx, 400, "username contains invalid characters") - } - - if (body.password.length < 8) { - return ServeError( - ctx, - 400, - "password must be 8 characters or longer" - ) - } - return Accounts.create(body.username, body.password) .then((account) => { login(ctx, account) diff --git a/src/server/routes/api/v1/session.ts b/src/server/routes/api/v1/session.ts index 590d1f3..7006dad 100644 --- a/src/server/routes/api/v1/session.ts +++ b/src/server/routes/api/v1/session.ts @@ -6,7 +6,7 @@ import { getCookie, setCookie } from "hono/cookie" // Libs -import Files, { id_check_regex } from "../../../lib/files.js" +import Files from "../../../lib/files.js" import * as Accounts from "../../../lib/accounts.js" import * as auth from "../../../lib/auth.js" import { diff --git a/src/server/cli.ts b/src/server/tools/cli.ts similarity index 92% rename from src/server/cli.ts rename to src/server/tools/cli.ts index f6ce73c..6a7fca0 100644 --- a/src/server/cli.ts +++ b/src/server/tools/cli.ts @@ -1,19 +1,19 @@ import fs from "fs" import { stat } from "fs/promises" -import Files from "./lib/files.js" +import Files from "../lib/files.js" import { program } from "commander" import { basename } from "path" import { Writable } from "node:stream" -import config from "./lib/config.js" -import pkg from "../../package.json" assert { type: "json" } +import config from "../lib/config.js" +import pkg from "../../../package.json" assert { type: "json" } import { fileURLToPath } from "url" import { dirname } from "path" // init data const __dirname = dirname(fileURLToPath(import.meta.url)) -if (!fs.existsSync(__dirname + "/../../.data/")) - fs.mkdirSync(__dirname + "/../../.data/") +if (!fs.existsSync(__dirname + "/../../../.data/")) + fs.mkdirSync(__dirname + "/../../../.data/") // discord let files = new Files(config)