Merge branch 'api-v1' into client-v2

This commit is contained in:
May 2024-05-02 21:48:44 -07:00
commit 82a7dad785
21 changed files with 407 additions and 312 deletions

View file

@ -8,8 +8,6 @@ DISCORD_TOKEN=
MAX__DISCORD_FILES= MAX__DISCORD_FILES=
MAX__DISCORD_FILE_SIZE= MAX__DISCORD_FILE_SIZE=
MAX__UPLOAD_ID_LENGTH= MAX__UPLOAD_ID_LENGTH=
TARGET__GUILD=
TARGET__CHANNEL= TARGET__CHANNEL=
ACCOUNTS__REGISTRATION_ENABLED= ACCOUNTS__REGISTRATION_ENABLED=

11
package-lock.json generated
View file

@ -27,7 +27,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",
@ -2130,6 +2131,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

@ -36,7 +36,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

@ -4,15 +4,12 @@ import { Hono } from "hono"
import fs from "fs" import fs from "fs"
import { readFile } from "fs/promises" import { readFile } from "fs/promises"
import Files from "./lib/files.js" import Files from "./lib/files.js"
import { getAccount } from "./lib/middleware.js"
import APIRouter from "./routes/api.js" import APIRouter from "./routes/api.js"
import preview from "./routes/api/web/preview.js"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { dirname } from "path" import { dirname } from "path"
import pkg from "../../package.json" assert { type: "json" } import config from "./lib/config.js"
import config, { ClientConfiguration } from "./lib/config.js"
const app = new Hono() const app = new Hono({strict: false})
app.get( app.get(
"/static/assets/*", "/static/assets/*",
@ -60,16 +57,6 @@ if (config.forceSSL) {
}) })
} }
app.get("/server", (ctx) =>
ctx.json({
version: pkg.version,
files: Object.keys(files.files).length,
maxDiscordFiles: config.maxDiscordFiles,
maxDiscordFileSize: config.maxDiscordFileSize,
accounts: config.accounts,
} as ClientConfiguration)
)
// funcs // funcs
// init data // init data
@ -87,6 +74,19 @@ apiRouter.loadAPIMethods().then(() => {
console.log("API OK!") console.log("API OK!")
// moved here to ensure it's matched last // moved here to ensure it's matched last
app.get("/server", async (ctx) =>
app.fetch(
new Request(
new URL(
"/api/v1",
ctx.req.raw.url
).href,
ctx.req.raw
),
ctx.env
)
)
app.get("/:fileId", async (ctx) => app.get("/:fileId", async (ctx) =>
app.fetch( app.fetch(
new Request( new Request(

View file

@ -7,7 +7,7 @@ import type { Configuration } from "../config.js"
const EXPIRE_AFTER = 20 * 60 * 1000 const EXPIRE_AFTER = 20 * 60 * 1000
const DISCORD_EPOCH = 1420070400000 const DISCORD_EPOCH = 1420070400000
// Converts a snowflake ID string into a JS Date object using the provided epoch (in ms), or Discord's epoch if not provided // Converts a snowflake ID string into a JS Date object using the provided epoch (in ms), or Discord's epoch if not provided
function convertSnowflakeToDate( export function convertSnowflakeToDate(
snowflake: string | number, snowflake: string | number,
epoch = DISCORD_EPOCH epoch = DISCORD_EPOCH
) { ) {

View file

@ -2,12 +2,14 @@ import crypto from "crypto"
import * as auth from "./auth.js"; import * as auth from "./auth.js";
import { readFile, writeFile } from "fs/promises" import { readFile, writeFile } from "fs/promises"
import { FileVisibility } from "./files.js"; import { FileVisibility } from "./files.js";
import { AccountSchemas } from "./schemas/index.js";
import { z } from "zod"
// this is probably horrible // this is probably horrible
// but i don't even care anymore // but i don't even care anymore
export let Accounts: Account[] = [] export let Accounts: Account[] = []
/*
export interface Account { export interface Account {
id : string id : string
username : string username : string
@ -25,7 +27,9 @@ export interface Account {
color? : string color? : string
largeImage? : boolean largeImage? : boolean
} }
} }*/
export type Account = z.infer<typeof AccountSchemas.Account>
/** /**
* @description Create a new account. * @description Create a new account.
@ -45,7 +49,8 @@ export async function create(username:string,pwd:string,admin:boolean=false):Pro
password: password.hash(pwd), password: password.hash(pwd),
files: [], files: [],
admin: admin, admin: admin,
defaultFileVisibility: "public" defaultFileVisibility: "public",
settings: AccountSchemas.Settings.User.parse({})
} }
) )
@ -144,7 +149,7 @@ export namespace files {
* @param fileId The target file's ID * @param fileId The target file's ID
* @returns Promise that resolves after accounts.json finishes writing * @returns Promise that resolves after accounts.json finishes writing
*/ */
export function index(accountId:string,fileId:string) { export function index(accountId:string,fileId:string,noWrite:boolean = false) {
// maybe replace with a obj like // maybe replace with a obj like
// { x:true } // { x:true }
// for faster lookups? not sure if it would be faster // for faster lookups? not sure if it would be faster
@ -153,7 +158,7 @@ export namespace files {
if (acc.files.find(e => e == fileId)) return if (acc.files.find(e => e == fileId)) return
acc.files.push(fileId) acc.files.push(fileId)
return save() if (!noWrite) return save()
} }
/** /**

View file

@ -9,13 +9,13 @@ export interface Configuration {
maxDiscordFiles: number maxDiscordFiles: number
maxDiscordFileSize: number maxDiscordFileSize: number
maxUploadIdLength: number maxUploadIdLength: number
targetGuild: string
targetChannel: string targetChannel: string
accounts: { accounts: {
registrationEnabled: boolean registrationEnabled: boolean
requiredForUpload: boolean requiredForUpload: boolean
} }
mail: { mail: {
enabled: boolean
transport: { transport: {
host: string host: string
port: number port: number
@ -32,6 +32,8 @@ export interface Configuration {
export interface ClientConfiguration { export interface ClientConfiguration {
version: string version: string
files: number files: number
totalSize: number
mailEnabled: boolean
maxDiscordFiles: number maxDiscordFiles: number
maxDiscordFileSize: number maxDiscordFileSize: number
accounts: { accounts: {
@ -49,7 +51,6 @@ export default {
maxDiscordFiles: Number(process.env.MAX__DISCORD_FILES), maxDiscordFiles: Number(process.env.MAX__DISCORD_FILES),
maxDiscordFileSize: Number(process.env.MAX__DISCORD_FILE_SIZE), maxDiscordFileSize: Number(process.env.MAX__DISCORD_FILE_SIZE),
maxUploadIdLength: Number(process.env.MAX__UPLOAD_ID_LENGTH), maxUploadIdLength: Number(process.env.MAX__UPLOAD_ID_LENGTH),
targetGuild: process.env.TARGET__GUILD,
targetChannel: process.env.TARGET__CHANNEL, targetChannel: process.env.TARGET__CHANNEL,
accounts: { accounts: {
registrationEnabled: registrationEnabled:
@ -58,6 +59,8 @@ export default {
}, },
mail: { mail: {
enabled: ["HOST","PORT","SEND_FROM","USER","PASS"].every(e => Boolean(process.env[`MAIL__${e}`])),
transport: { transport: {
host: process.env.MAIL__HOST, host: process.env.MAIL__HOST,
port: Number(process.env.MAIL__PORT), port: Number(process.env.MAIL__PORT),

View file

@ -2,28 +2,31 @@ import { readFile, writeFile } from "node:fs/promises"
import { Readable, Writable } from "node:stream" import { Readable, Writable } from "node:stream"
import crypto from "node:crypto" import crypto from "node:crypto"
import { files } from "./accounts.js" import { files } from "./accounts.js"
import { Client as API } from "./DiscordAPI/index.js" import { Client as API, convertSnowflakeToDate } from "./DiscordAPI/index.js"
import type { APIAttachment } from "discord-api-types/v10" import type { APIAttachment } from "discord-api-types/v10"
import config, { Configuration } from "./config.js" 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"
import { issuesToMessage } from "./middleware.js"
import file from "../routes/api/v1/file/index.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 +34,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 +446,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 +502,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, issuesToMessage(check.error.issues)))
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"))
@ -651,28 +625,37 @@ export default class Files {
} }
/** /**
* @description Update a file from monofile 1.2 to allow for range requests with Content-Length to that file. * @description Update a file from monofile 1.x to 2.x
* @param uploadId Target file's ID * @param uploadId Target file's ID
*/ */
async update(uploadId: string) { async update(uploadId: string) {
let target_file = this.files[uploadId] let target_file = this.files[uploadId]
let attachment_sizes = [] let attachments: APIAttachment[] = []
for (let message of target_file.messageids) { for (let message of target_file.messageids) {
let attachments = (await this.api.fetchMessage(message)).attachments let attachments = (await this.api.fetchMessage(message)).attachments
for (let attachment of attachments) { for (let attachment of attachments) {
attachment_sizes.push(attachment.size) attachments.push(attachment)
} }
} }
if (!target_file.sizeInBytes) if (!target_file.sizeInBytes)
target_file.sizeInBytes = attachment_sizes.reduce( target_file.sizeInBytes = attachments.reduce(
(a, b) => a + b, (a, b) => a + b.size,
0 0
) )
if (!target_file.chunkSize) target_file.chunkSize = attachment_sizes[0] if (!target_file.chunkSize) target_file.chunkSize = attachments[0].size
if (!target_file.lastModified) target_file.lastModified = convertSnowflakeToDate(target_file.messageids[target_file.messageids.length-1]).getTime()
// this feels like needlessly heavy
// we should probably just do this in an actual readFile idk
if (!target_file.md5) {
let hash = crypto.createHash("md5");
(await this.readFileStream(uploadId)).pipe(hash).once("end", () => target_file.md5 = hash.digest("hex"))
}
} }
/** /**
@ -713,8 +696,41 @@ export default class Files {
delete this.files[uploadId] delete this.files[uploadId]
if (!noWrite) if (!noWrite)
this.write().catch((err) => { return this.write()
throw err }
})
async chown(uploadId: string, newOwner?: string, noWrite: boolean = false) {
let target = this.files[uploadId]
if (target.owner) {
let i = files.deindex(target.owner, uploadId, Boolean(newOwner && noWrite))
if (i) await i
}
target.owner = newOwner
if (newOwner) {
let i = files.index(newOwner, uploadId, noWrite)
if (i) await i
}
if (!noWrite)
return this.write()
}
async mv(uploadId: string, newId: string, noWrite: boolean = false) {
let target = this.files[uploadId]
if (target.owner) {
let owner = Accounts.getFromId(target.owner)
if (owner) {
owner.files.splice(owner.files.indexOf(uploadId), 1, newId)
if (!noWrite)
await Accounts.save()
}
}
this.files[newId] = target
delete this.files[uploadId]
if (!noWrite)
return this.write()
} }
} }

View file

@ -3,12 +3,16 @@ 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 { z } 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")
*/ */
export const getAccount: RequestHandler = function (ctx, next) { export const getAccount: RequestHandler = function (ctx, next) {
ctx.set("account", Accounts.getFromToken(auth.tokenFor(ctx)!)) let account = Accounts.getFromToken(auth.tokenFor(ctx)!)
if (account?.suspension)
setCookie(ctx, "auth", "")
ctx.set("account", account)
return next() return next()
} }
@ -37,7 +41,6 @@ export const requiresAdmin: RequestHandler = function (ctx, next) {
* @param tokenPermissions Permissions which your route requires. * @param tokenPermissions Permissions which your route requires.
* @returns Express middleware * @returns Express middleware
*/ */
export const requiresPermissions = function ( export const requiresPermissions = function (
...tokenPermissions: auth.TokenPermission[] ...tokenPermissions: auth.TokenPermission[]
): RequestHandler { ): RequestHandler {
@ -93,6 +96,19 @@ export const assertAPI = function (
} }
} }
export const issuesToMessage = function(issues: z.ZodIssue[]) {
return issues.map(e => `${e.path}: ${e.code} :: ${e.message}`).join("; ")
}
export const scheme = function(scheme: z.ZodTypeAny): RequestHandler {
return async function(ctx, next) {
let chk = scheme.safeParse(await ctx.req.json())
ctx.set("parsedScheme", chk.data)
if (chk.success) return next()
else return ServeError(ctx, 400, issuesToMessage(chk.error.issues))
}
}
// Not really middleware but a utility // Not really middleware but a utility
export const login = (ctx: Context, account: string) => setCookie(ctx, "auth", auth.create(account, 3 * 24 * 60 * 60 * 1000), { export const login = (ctx: Context, account: string) => setCookie(ctx, "auth", auth.create(account, 3 * 24 * 60 * 60 * 1000), {
@ -101,21 +117,3 @@ export const login = (ctx: Context, account: string) => setCookie(ctx, "auth", a
secure: true, secure: true,
httpOnly: true httpOnly: true
}) })
type SchemeType = "array" | "object" | "string" | "number" | "boolean"
interface SchemeObject {
type: "object"
children: {
[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,73 @@
import {z} from "zod"
import { FileId, 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 namespace Settings {
export const Theme = z.discriminatedUnion("theme", [
z.object({
theme: z.literal("catppuccin"),
variant: z.enum(["latte","frappe","macchiato","mocha","adaptive"]),
accent: z.enum([
"rosewater",
"flamingo",
"pink",
"mauve",
"red",
"maroon",
"peach",
"yellow",
"green",
"teal",
"sky",
"sapphire",
"blue",
"lavender"
])
}),
z.object({
theme: z.literal("custom"),
id: FileId
})
])
export const BarSide = z.enum(["top","left","bottom","right"])
export const Interface = z.object({
theme: Theme.default({theme: "catppuccin", variant: "adaptive", accent: "sky"}),
barSide: BarSide.default("left")
})
export const Links = z.object({
color: z.string().toLowerCase().length(6).regex(/^[a-f0-9]+$/,"illegal characters").optional(),
largeImage: z.boolean().default(false)
})
export const User = z.object({
interface: Interface.default({}), links: Links.default({})
})
}
export const Suspension =
z.object({
reason: z.string(),
until: z.number().nullable()
})
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,
settings: Settings.User,
suspension: Suspension.optional()
})

View file

@ -0,0 +1,21 @@
import {z} from "zod"
import config from "../config.js"
export const FileId = z.string()
.regex(/^[A-Za-z0-9_\-\.\!\=\:\&\$\,\+\;\@\~\*\(\)\']+$/,"file ID uses invalid characters")
.max(config.maxUploadIdLength,"file ID too long")
.min(1, "you... *need* a file ID")
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,21 +5,25 @@ 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 {
assertAPI, assertAPI,
getAccount, getAccount,
issuesToMessage,
login, login,
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: {
@ -40,8 +44,7 @@ type Message = [200 | 400 | 401 | 403 | 429 | 501, string]
// @Jack5079 make typings better if possible // @Jack5079 make typings better if possible
type Validator< type Validator<
T extends keyof Partial<Accounts.Account>, T extends keyof Partial<Accounts.Account>
ValueNotNull extends boolean,
> = > =
/** /**
* @param actor The account performing this action * @param actor The account performing this action
@ -52,44 +55,33 @@ type Validator<
actor: Accounts.Account, actor: Accounts.Account,
target: Accounts.Account, target: Accounts.Account,
params: UserUpdateParameters & params: UserUpdateParameters &
(ValueNotNull extends true {
? {
[K in keyof Pick< [K in keyof Pick<
UserUpdateParameters, UserUpdateParameters,
T T
>]-?: UserUpdateParameters[K] >]-?: UserUpdateParameters[K]
} },
: {}),
ctx: Context ctx: Context
) => Accounts.Account[T] | Message ) => Accounts.Account[T] | Message
// this type is so stupid stg type SchemedValidator<
type ValidatorWithSettings<T extends keyof Partial<Accounts.Account>> = T extends keyof Partial<Accounts.Account>
| { > = {
acceptsNull: true validator: Validator<T>,
validator: Validator<T, false> schema: z.ZodTypeAny
} }
| {
acceptsNull?: false
validator: Validator<T, true>
}
const validators: { const validators: {
[T in keyof Partial<Accounts.Account>]: [T in keyof Partial<Accounts.Account>]: SchemedValidator<T>
| Validator<T, true>
| ValidatorWithSettings<T>
} = { } = {
defaultFileVisibility(actor, target, params) { defaultFileVisibility: {
if ( schema: FileSchemas.FileVisibility,
["public", "private", "anonymous"].includes( validator: (actor, target, params) => {
params.defaultFileVisibility
)
)
return params.defaultFileVisibility return params.defaultFileVisibility
else return [400, "invalid file visibility"] }
}, },
email: { email: {
acceptsNull: true, schema: AccountSchemas.Account.shape.email.nullable(),
validator: (actor, target, params, ctx) => { validator: (actor, target, params, ctx) => {
if ( if (
!params.currentPassword || // actor on purpose here to allow admins !params.currentPassword || // actor on purpose here to allow admins
@ -109,9 +101,7 @@ const validators: {
return undefined return undefined
} }
if (typeof params.email !== "string") if (actor.admin) return params.email || undefined
return [400, "email must be string"]
if (actor.admin) return params.email
// send verification email // send verification email
@ -142,7 +132,9 @@ const validators: {
return [200, "please check your inbox"] return [200, "please check your inbox"]
}, },
}, },
password(actor, target, params) { password: {
schema: AccountSchemas.StringPassword,
validator: (actor, target, params) => {
if ( if (
!params.currentPassword || // actor on purpose here to allow admins !params.currentPassword || // actor on purpose here to allow admins
(params.currentPassword && (params.currentPassword &&
@ -150,9 +142,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,
@ -164,8 +153,11 @@ const validators: {
} }
return Accounts.password.hash(params.password) return Accounts.password.hash(params.password)
}
}, },
username(actor, target, params) { username: {
schema: AccountSchemas.Username,
validator: (actor, target, params) => {
if ( if (
!params.currentPassword || // actor on purpose here to allow admins !params.currentPassword || // actor on purpose here to allow admins
(params.currentPassword && (params.currentPassword &&
@ -173,25 +165,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,
@ -203,45 +179,39 @@ const validators: {
} }
return params.username return params.username
}
}, },
customCSS: { admin: {
acceptsNull: true, schema: z.boolean(),
validator: (actor, target, params) => { validator: (actor, target, params) => {
if (
!params.customCSS ||
(params.customCSS.match(id_check_regex)?.[0] ==
params.customCSS &&
params.customCSS.length <= Configuration.maxUploadIdLength)
)
return params.customCSS
else return [400, "bad file id"]
},
},
embed(actor, target, params) {
if (typeof params.embed !== "object")
return [400, "must use an object for embed"]
if (params.embed.color === undefined) {
params.embed.color = target.embed?.color
} else if (
!(
(params.embed.color.toLowerCase().match(/[a-f0-9]+/)?.[0] ==
params.embed.color.toLowerCase() &&
params.embed.color.length == 6) ||
params.embed.color == null
)
)
return [400, "bad embed color"]
if (params.embed.largeImage === undefined) {
params.embed.largeImage = target.embed?.largeImage
} else params.embed.largeImage = Boolean(params.embed.largeImage)
return params.embed
},
admin(actor, target, params) {
if (actor.admin && !target.admin) return params.admin if (actor.admin && !target.admin) return params.admin
else if (!actor.admin) return [400, "cannot promote yourself"] else if (!actor.admin) return [400, "cannot promote yourself"]
else return [400, "cannot demote an admin"] else return [400, "cannot demote an admin"]
}
},
suspension: {
schema: AccountSchemas.Suspension.nullable(),
validator: (actor, target, params) => {
if (!actor.admin) return [400, "only admins can modify suspensions"]
return params.suspension || undefined
}
},
settings: {
schema: AccountSchemas.Settings.User.partial(),
validator: (actor, target, params) => {
let base = AccountSchemas.Settings.User.default({}).parse(target.settings)
let visit = (bse: Record<string, any>, nw: Record<string, any>) => {
for (let [key,value] of Object.entries(nw)) {
if (typeof value == "object") visit(bse[key], value)
else bse[key] = value
}
}
visit(base, params.settings)
return AccountSchemas.Settings.User.parse(base) // so that toLowerCase is called again... yeah that's it
}
}, },
} }
@ -272,7 +242,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,35 +263,14 @@ 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)
return ctx.text("logged in") return ctx.text("logged in")
}) })
.catch(() => { .catch((e) => {
return ServeError(ctx, 500, "internal server error") console.error(e)
return ServeError(ctx, 500, e instanceof z.ZodError ? issuesToMessage(e.issues) : "internal server error")
}) })
}) })
@ -352,23 +304,11 @@ export default function (files: Files) {
`the ${x} parameter cannot be set or is not a valid parameter`, `the ${x} parameter cannot be set or is not a valid parameter`,
] as Message ] as Message
let validator = ( let validator = validators[x]!
typeof validators[x] == "object"
? validators[x]
: {
validator: validators[x] as Validator<
typeof x,
false
>,
acceptsNull: false,
}
) as ValidatorWithSettings<typeof x>
if (!validator.acceptsNull && !v) let check = validator.schema.safeParse(v)
return [ if (!check.success)
400, return [400, issuesToMessage(check.error.issues)]
`the ${x} validator does not accept null values`,
] as Message
return [ return [
x, x,
@ -437,11 +377,5 @@ export default function (files: Files) {
}) })
}) })
router.get("/css", async (ctx) => {
let acc = ctx.get("account")
if (acc?.customCSS) return ctx.redirect(`/file/${acc.customCSS}`)
else return ctx.text("")
})
return router return router
} }

View file

@ -4,6 +4,10 @@
"mount": [ "mount": [
"account", "account",
"session", "session",
{
"file": "index",
"to": "/"
},
{ {
"file": "file/index", "file": "file/index",
"to": "/file" "to": "/file"

View file

@ -4,17 +4,20 @@ import * as auth from "../../../../lib/auth.js"
import RangeParser, { type Range } from "range-parser" import RangeParser, { type Range } from "range-parser"
import ServeError from "../../../../lib/errors.js" import ServeError from "../../../../lib/errors.js"
import Files, { WebError } from "../../../../lib/files.js" import Files, { WebError } from "../../../../lib/files.js"
import { getAccount, requiresPermissions } from "../../../../lib/middleware.js" import { getAccount, requiresAccount, requiresPermissions, scheme } from "../../../../lib/middleware.js"
import {Readable} from "node:stream" import {Readable} from "node:stream"
import type {ReadableStream as StreamWebReadable} from "node:stream/web" import type {ReadableStream as StreamWebReadable} from "node:stream/web"
import formidable from "formidable" import formidable from "formidable"
import { HttpBindings } from "@hono/node-server" import { HttpBindings } from "@hono/node-server"
import pkg from "../../../../../../package.json" assert {type: "json"} import pkg from "../../../../../../package.json" assert {type: "json"}
import { type StatusCode } from "hono/utils/http-status" import { type StatusCode } from "hono/utils/http-status"
import { z } from "zod"
import { FileSchemas } from "../../../../lib/schemas/index.js"
const router = new Hono<{ const router = new Hono<{
Variables: { Variables: {
account: Accounts.Account account: Accounts.Account,
parsedSchema: any
}, },
Bindings: HttpBindings Bindings: HttpBindings
}>() }>()

View file

@ -0,0 +1,30 @@
import { Hono } from "hono"
import * as Accounts from "../../../lib/accounts.js"
import { HttpBindings } from "@hono/node-server"
import pkg from "../../../../../package.json" assert {type: "json"}
import config, { ClientConfiguration } from "../../../lib/config.js"
import type Files from "../../../lib/files.js"
const router = new Hono<{
Variables: {
account: Accounts.Account
},
Bindings: HttpBindings
}>()
export default function(files: Files) {
router.get("/", async (ctx) =>
ctx.json({
version: pkg.version,
files: Object.keys(files.files).length,
totalSize: Object.values(files.files).filter(e => e.sizeInBytes).reduce((acc,cur)=>acc+cur.sizeInBytes!,0),
maxDiscordFiles: config.maxDiscordFiles,
maxDiscordFileSize: config.maxDiscordFileSize,
accounts: config.accounts,
mailEnabled: config.mail.enabled
} as ClientConfiguration)
)
return router
}

View file

@ -6,15 +6,18 @@ 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 {
getAccount, getAccount,
login, login,
requiresAccount requiresAccount,
scheme
} from "../../../lib/middleware.js" } from "../../../lib/middleware.js"
import ServeError from "../../../lib/errors.js" import ServeError from "../../../lib/errors.js"
import { AccountSchemas } from "../../../lib/schemas/index.js"
import { z } from "zod"
const router = new Hono<{ const router = new Hono<{
Variables: { Variables: {
@ -25,26 +28,30 @@ const router = new Hono<{
router.use(getAccount) router.use(getAccount)
export default function (files: Files) { export default function (files: Files) {
router.post("/", async (ctx, res) => { 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 (
typeof body.username != "string" ||
typeof body.password != "string"
) {
ServeError(ctx, 400, "please provide a username or password")
return
}
if (auth.validate(getCookie(ctx, "auth")!)) { if (ctx.get("account"))
ServeError(ctx, 400, "you are already logged in") return ServeError(ctx, 400, "you are already logged in")
return
}
const account = Accounts.getFromUsername(body.username) const account = Accounts.getFromUsername(body.username)
if (!account || !Accounts.password.check(account.id, body.password)) { if (!account || !Accounts.password.check(account.id, body.password)) {
ServeError(ctx, 400, "username or password incorrect") return ServeError(ctx, 400, "username or password incorrect")
return }
if (account.suspension) {
if (account.suspension.until && Date.now() > account.suspension.until) delete account.suspension;
else return ServeError(
ctx,
403,
`account ${account.suspension.until
? `suspended until ${new Date(account.suspension.until).toUTCString()}`
: "suspended indefinitely"
}: ${account.suspension.reason}`)
} }
login(ctx, account.id) login(ctx, account.id)
@ -60,12 +67,8 @@ export default function (files: Files) {
}) })
}) })
router.delete("/", (ctx) => { router.delete("/", requiresAccount, (ctx) => {
if (!auth.validate(getCookie(ctx, "auth")!)) { auth.invalidate(auth.tokenFor(ctx)!)
return ServeError(ctx, 401, "not logged in")
}
auth.invalidate(getCookie(ctx, "auth")!)
return ctx.text("logged out") return ctx.text("logged out")
}) })

View file

@ -75,18 +75,18 @@ export default function (files: Files) {
<meta property="og:video:height" content="720">` <meta property="og:video:height" content="720">`
: "") : "")
: "") + : "") +
(fileOwner?.embed?.largeImage && (fileOwner?.settings?.links?.largeImage &&
file.visibility != "anonymous" && file.visibility != "anonymous" &&
file.mime.startsWith("image/") file.mime.startsWith("image/")
? `<meta name="twitter:card" content="summary_large_image">` ? `<meta name="twitter:card" content="summary_large_image">`
: "") + : "") +
`\n<meta name="theme-color" content="${ `\n<meta name="theme-color" content="${
fileOwner?.embed?.color && fileOwner?.settings?.links.color &&
file.visibility != "anonymous" && file.visibility != "anonymous" &&
(ctx.req.header("user-agent") || "").includes( (ctx.req.header("user-agent") || "").includes(
"Discordbot" "Discordbot"
) )
? `#${fileOwner.embed.color}` ? `#${fileOwner?.settings?.links.color}`
: "rgb(30, 33, 36)" : "rgb(30, 33, 36)"
}">` }">`
) )

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)