mirror of
https://github.com/mollersuite/monofile.git
synced 2024-11-21 13:36:25 -08:00
Merge branch 'api-v1' into client-v2
This commit is contained in:
commit
82a7dad785
|
@ -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
11
package-lock.json
generated
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
|
||||||
|
|
73
src/server/lib/schemas/accounts.ts
Normal file
73
src/server/lib/schemas/accounts.ts
Normal 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()
|
||||||
|
})
|
21
src/server/lib/schemas/files.ts
Normal file
21
src/server/lib/schemas/files.ts
Normal 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())
|
||||||
|
})
|
2
src/server/lib/schemas/index.ts
Normal file
2
src/server/lib/schemas/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * as AccountSchemas from "./accounts.js"
|
||||||
|
export * as FileSchemas from "./files.js"
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,106 +132,86 @@ const validators: {
|
||||||
return [200, "please check your inbox"]
|
return [200, "please check your inbox"]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
password(actor, target, params) {
|
password: {
|
||||||
if (
|
schema: AccountSchemas.StringPassword,
|
||||||
!params.currentPassword || // actor on purpose here to allow admins
|
|
||||||
(params.currentPassword &&
|
|
||||||
Accounts.password.check(actor.id, params.currentPassword))
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
`Your login details have been updated`,
|
|
||||||
`<b>Hello there!</b> Your password on your account, <span username>${target.username}</span>, has been updated` +
|
|
||||||
`${actor != target ? ` by <span username>${actor.username}</span>` : ""}. ` +
|
|
||||||
`Please update your saved login details accordingly.`
|
|
||||||
).catch()
|
|
||||||
}
|
|
||||||
|
|
||||||
return Accounts.password.hash(params.password)
|
|
||||||
},
|
|
||||||
username(actor, target, params) {
|
|
||||||
if (
|
|
||||||
!params.currentPassword || // actor on purpose here to allow admins
|
|
||||||
(params.currentPassword &&
|
|
||||||
Accounts.password.check(actor.id, params.currentPassword))
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
`Your login details have been updated`,
|
|
||||||
`<b>Hello there!</b> Your username on your account, <span username>${target.username}</span>, has been updated` +
|
|
||||||
`${actor != target ? ` by <span username>${actor.username}</span>` : ""} to <span username>${params.username}</span>. ` +
|
|
||||||
`Please update your saved login details accordingly.`
|
|
||||||
).catch()
|
|
||||||
}
|
|
||||||
|
|
||||||
return params.username
|
|
||||||
},
|
|
||||||
customCSS: {
|
|
||||||
acceptsNull: true,
|
|
||||||
validator: (actor, target, params) => {
|
validator: (actor, target, params) => {
|
||||||
if (
|
if (
|
||||||
!params.customCSS ||
|
!params.currentPassword || // actor on purpose here to allow admins
|
||||||
(params.customCSS.match(id_check_regex)?.[0] ==
|
(params.currentPassword &&
|
||||||
params.customCSS &&
|
Accounts.password.check(actor.id, params.currentPassword))
|
||||||
params.customCSS.length <= Configuration.maxUploadIdLength)
|
|
||||||
)
|
)
|
||||||
return params.customCSS
|
return [401, "current password incorrect"]
|
||||||
else return [400, "bad file id"]
|
|
||||||
},
|
if (target.email) {
|
||||||
|
sendMail(
|
||||||
|
target.email,
|
||||||
|
`Your login details have been updated`,
|
||||||
|
`<b>Hello there!</b> Your password on your account, <span username>${target.username}</span>, has been updated` +
|
||||||
|
`${actor != target ? ` by <span username>${actor.username}</span>` : ""}. ` +
|
||||||
|
`Please update your saved login details accordingly.`
|
||||||
|
).catch()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Accounts.password.hash(params.password)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
embed(actor, target, params) {
|
username: {
|
||||||
if (typeof params.embed !== "object")
|
schema: AccountSchemas.Username,
|
||||||
return [400, "must use an object for embed"]
|
validator: (actor, target, params) => {
|
||||||
if (params.embed.color === undefined) {
|
if (
|
||||||
params.embed.color = target.embed?.color
|
!params.currentPassword || // actor on purpose here to allow admins
|
||||||
} else if (
|
(params.currentPassword &&
|
||||||
!(
|
Accounts.password.check(actor.id, params.currentPassword))
|
||||||
(params.embed.color.toLowerCase().match(/[a-f0-9]+/)?.[0] ==
|
|
||||||
params.embed.color.toLowerCase() &&
|
|
||||||
params.embed.color.length == 6) ||
|
|
||||||
params.embed.color == null
|
|
||||||
)
|
)
|
||||||
)
|
return [401, "current password incorrect"]
|
||||||
return [400, "bad embed color"]
|
|
||||||
|
|
||||||
if (params.embed.largeImage === undefined) {
|
if (Accounts.getFromUsername(params.username))
|
||||||
params.embed.largeImage = target.embed?.largeImage
|
return [400, "account with this username already exists"]
|
||||||
} else params.embed.largeImage = Boolean(params.embed.largeImage)
|
|
||||||
|
|
||||||
return params.embed
|
if (target.email) {
|
||||||
|
sendMail(
|
||||||
|
target.email,
|
||||||
|
`Your login details have been updated`,
|
||||||
|
`<b>Hello there!</b> Your username on your account, <span username>${target.username}</span>, has been updated` +
|
||||||
|
`${actor != target ? ` by <span username>${actor.username}</span>` : ""} to <span username>${params.username}</span>. ` +
|
||||||
|
`Please update your saved login details accordingly.`
|
||||||
|
).catch()
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.username
|
||||||
|
}
|
||||||
},
|
},
|
||||||
admin(actor, target, params) {
|
admin: {
|
||||||
if (actor.admin && !target.admin) return params.admin
|
schema: z.boolean(),
|
||||||
else if (!actor.admin) return [400, "cannot promote yourself"]
|
validator: (actor, target, params) => {
|
||||||
else return [400, "cannot demote an admin"]
|
if (actor.admin && !target.admin) return params.admin
|
||||||
|
else if (!actor.admin) return [400, "cannot promote yourself"]
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,10 @@
|
||||||
"mount": [
|
"mount": [
|
||||||
"account",
|
"account",
|
||||||
"session",
|
"session",
|
||||||
|
{
|
||||||
|
"file": "index",
|
||||||
|
"to": "/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "file/index",
|
"file": "file/index",
|
||||||
"to": "/file"
|
"to": "/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
|
||||||
}>()
|
}>()
|
||||||
|
|
30
src/server/routes/api/v1/index.ts
Normal file
30
src/server/routes/api/v1/index.ts
Normal 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
|
||||||
|
}
|
|
@ -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")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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)"
|
||||||
}">`
|
}">`
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
Loading…
Reference in a new issue