initial commit

This commit is contained in:
May 2024-04-30 21:25:54 -07:00
parent e4a4e24d6c
commit 8a26ace11f
12 changed files with 93 additions and 130 deletions

11
package-lock.json generated
View file

@ -26,7 +26,8 @@
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"nodemailer": "^6.9.3", "nodemailer": "^6.9.3",
"typescript": "^5.2.2" "typescript": "^5.2.2",
"zod": "^3.23.5"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.4.6", "@sveltejs/vite-plugin-svelte": "^2.4.6",
@ -1941,6 +1942,14 @@
"engines": { "engines": {
"node": ">=0.4" "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"
}
} }
} }
} }

View file

@ -35,7 +35,8 @@
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"nodemailer": "^6.9.3", "nodemailer": "^6.9.3",
"typescript": "^5.2.2" "typescript": "^5.2.2",
"zod": "^3.23.5"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.4.6", "@sveltejs/vite-plugin-svelte": "^2.4.6",

View file

@ -8,22 +8,23 @@ import config, { Configuration } from "./config.js"
import "dotenv/config" import "dotenv/config"
import * as Accounts from "./accounts.js" 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( export let alphanum = Array.from(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
) )
// bad solution but whatever // bad solution but whatever
export type FileVisibility = "public" | "anonymous" | "private" export type FileVisibility = z.infer<typeof schemas.FileVisibility>
/** /**
* @description Generates an alphanumeric string, used for files * @description Generates an alphanumeric string, used for files
* @param length Length of the ID * @param length Length of the ID
* @returns a random alphanumeric string * @returns a random alphanumeric string
*/ */
export function generateFileId(length: number = 5) { export function generateFileId(length: number = 5): z.infer<typeof schemas.FileId> {
let fid = "" let fid = ""
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
fid += alphanum[crypto.randomInt(0, alphanum.length)] fid += alphanum[crypto.randomInt(0, alphanum.length)]
@ -31,35 +32,7 @@ export function generateFileId(length: number = 5) {
return fid return fid
} }
/** export type FilePointer = z.infer<typeof schemas.FilePointer>
* @description Assert multiple conditions... this exists out of pure laziness
* @param conditions
*/
function multiAssert(
conditions: Map<boolean, { message: string; status: number }>
) {
for (let [cond, err] of conditions.entries()) {
if (cond) return err
}
}
export type FileUploadSettings = Partial<Pick<FilePointer, "mime" | "owner">> &
Pick<FilePointer, "mime" | "filename"> & { 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 interface StatusCodeError { export interface StatusCodeError {
status: number status: number
@ -471,8 +444,8 @@ export class UploadStream extends Writable {
visibility: ogf visibility: ogf
? ogf.visibility ? ogf.visibility
: this.owner : this.owner
? Accounts.getFromId(this.owner)?.defaultFileVisibility && Accounts.getFromId(this.owner)?.defaultFileVisibility
: undefined, || "public",
// so that json.stringify doesnt include tag:undefined // so that json.stringify doesnt include tag:undefined
...((ogf || {}).tag ? { tag: ogf.tag } : {}), ...((ogf || {}).tag ? { tag: ogf.tag } : {}),
@ -527,12 +500,11 @@ export class UploadStream extends Writable {
return this.destroy( return this.destroy(
new WebError(400, "duplicate attempt to set upload ID") new WebError(400, "duplicate attempt to set upload ID")
) )
if (
!id || let check = schemas.FileId.safeParse(id);
id.match(id_check_regex)?.[0] != id ||
id.length > this.files.config.maxUploadIdLength if (!check.success)
) return this.destroy(new WebError(400, check.error.message))
return this.destroy(new WebError(400, "invalid file ID"))
if (this.files.files[id] && this.files.files[id].owner != this.owner) if (this.files.files[id] && this.files.files[id].owner != this.owner)
return this.destroy(new WebError(403, "you don't own this file")) return this.destroy(new WebError(403, "you don't own this file"))

View file

@ -3,6 +3,7 @@ import type { Context, Handler as RequestHandler } from "hono"
import ServeError from "../lib/errors.js" import ServeError from "../lib/errors.js"
import * as auth from "./auth.js" import * as auth from "./auth.js"
import { setCookie } from "hono/cookie" import { setCookie } from "hono/cookie"
import { ZodObject } from "zod"
/** /**
* @description Middleware which adds an account, if any, to ctx.get("account") * @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 httpOnly: true
}) })
type SchemeType = "array" | "object" | "string" | "number" | "boolean" export const scheme = function(scheme: ZodObject<any>): RequestHandler {
return function(ctx, next) {
interface SchemeObject { let chk = scheme.safeParse(ctx.req.json())
type: "object" if (chk.success) next()
children: { else ServeError(ctx, 400, chk.error.message)
[key: string]: SchemeParameter
} }
} }
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

View file

@ -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
})

View file

@ -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())
})

View file

@ -0,0 +1,2 @@
export * as AccountSchemas from "./accounts.js"
export * as FileSchemas from "./files.js"

View file

@ -14,8 +14,7 @@ import config from "../../../lib/config.js"
import ServeError from "../../../lib/errors.js" import ServeError from "../../../lib/errors.js"
import Files, { import Files, {
FileVisibility, FileVisibility,
generateFileId, generateFileId
id_check_regex,
} from "../../../lib/files.js" } from "../../../lib/files.js"
import { writeFile } from "fs/promises" import { writeFile } from "fs/promises"

View file

@ -68,10 +68,6 @@ export default function (files: Files) {
let fp = files.files[e] let fp = files.files[e]
if (fp.reserved) {
return
}
switch (body.action) { switch (body.action) {
case "delete": case "delete":
files.unlink(e, true) files.unlink(e, true)

View file

@ -5,7 +5,7 @@ import { getCookie, setCookie } from "hono/cookie"
// Libs // 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 Accounts from "../../../lib/accounts.js"
import * as auth from "../../../lib/auth.js" import * as auth from "../../../lib/auth.js"
import { import {
@ -15,11 +15,14 @@ import {
noAPIAccess, noAPIAccess,
requiresAccount, requiresAccount,
requiresPermissions, requiresPermissions,
scheme,
} from "../../../lib/middleware.js" } from "../../../lib/middleware.js"
import ServeError from "../../../lib/errors.js" import ServeError from "../../../lib/errors.js"
import { CodeMgr, sendMail } from "../../../lib/mail.js" import { CodeMgr, sendMail } from "../../../lib/mail.js"
import Configuration from "../../../lib/config.js" import Configuration from "../../../lib/config.js"
import { AccountSchemas, FileSchemas } from "../../../lib/schemas/index.js"
import { z } from "zod"
const router = new Hono<{ const router = new Hono<{
Variables: { Variables: {
@ -80,13 +83,7 @@ const validators: {
| ValidatorWithSettings<T> | ValidatorWithSettings<T>
} = { } = {
defaultFileVisibility(actor, target, params) { defaultFileVisibility(actor, target, params) {
if ( return params.defaultFileVisibility
["public", "private", "anonymous"].includes(
params.defaultFileVisibility
)
)
return params.defaultFileVisibility
else return [400, "invalid file visibility"]
}, },
email: { email: {
acceptsNull: true, acceptsNull: true,
@ -109,8 +106,8 @@ const validators: {
return undefined return undefined
} }
if (typeof params.email !== "string") if (!z.string().email().safeParse(typeof params.email).success)
return [400, "email must be string"] return [400, "bad email"]
if (actor.admin) return params.email if (actor.admin) return params.email
// send verification email // send verification email
@ -150,9 +147,6 @@ const validators: {
) )
return [401, "current password incorrect"] 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) { if (target.email) {
sendMail( sendMail(
target.email, target.email,
@ -173,25 +167,9 @@ const validators: {
) )
return [401, "current password incorrect"] 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)) if (Accounts.getFromUsername(params.username))
return [400, "account with this username already exists"] 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) { if (target.email) {
sendMail( sendMail(
target.email, target.email,
@ -207,12 +185,7 @@ const validators: {
customCSS: { customCSS: {
acceptsNull: true, acceptsNull: true,
validator: (actor, target, params) => { validator: (actor, target, params) => {
if ( if (FileSchemas.FileId.safeParse(params.customCSS).success)
!params.customCSS ||
(params.customCSS.match(id_check_regex)?.[0] ==
params.customCSS &&
params.customCSS.length <= Configuration.maxUploadIdLength)
)
return params.customCSS return params.customCSS
else return [400, "bad file id"] else return [400, "bad file id"]
}, },
@ -272,7 +245,10 @@ function isMessage(object: any): object is Message {
} }
export default function (files: Files) { 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() const body = await ctx.req.json()
if (!Configuration.accounts.registrationEnabled) { if (!Configuration.accounts.registrationEnabled) {
return ServeError(ctx, 403, "account registration disabled") 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) return Accounts.create(body.username, body.password)
.then((account) => { .then((account) => {
login(ctx, account) login(ctx, account)

View file

@ -6,7 +6,7 @@ import { getCookie, setCookie } from "hono/cookie"
// Libs // 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 Accounts from "../../../lib/accounts.js"
import * as auth from "../../../lib/auth.js" import * as auth from "../../../lib/auth.js"
import { import {

View file

@ -1,19 +1,19 @@
import fs from "fs" import fs from "fs"
import { stat } from "fs/promises" import { stat } from "fs/promises"
import Files from "./lib/files.js" import Files from "../lib/files.js"
import { program } from "commander" import { program } from "commander"
import { basename } from "path" import { basename } from "path"
import { Writable } from "node:stream" import { Writable } from "node:stream"
import config from "./lib/config.js" import config from "../lib/config.js"
import pkg from "../../package.json" assert { type: "json" } import pkg from "../../../package.json" assert { type: "json" }
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { dirname } from "path" import { dirname } from "path"
// init data // init data
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
if (!fs.existsSync(__dirname + "/../../.data/")) if (!fs.existsSync(__dirname + "/../../../.data/"))
fs.mkdirSync(__dirname + "/../../.data/") fs.mkdirSync(__dirname + "/../../../.data/")
// discord // discord
let files = new Files(config) let files = new Files(config)