Merge pull request #62 from cirroskais/api-v1

unify configuration (use environment variables) and dockerize
This commit is contained in:
split / May 2024-04-29 13:00:38 -07:00 committed by GitHub
commit 2112c75a7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 879 additions and 558 deletions

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
.vscode
.gitignore
.prettierrc
LICENSE
README.md
node_modules
.env
.data
out
dist
tsconfig.tsbuildinfo

23
.env.example Normal file
View file

@ -0,0 +1,23 @@
PORT=
REQUEST_TIMEOUT=
TRUST_PROXY=
FORCE_SSL=
DISCORD_TOKEN=
MAX__DISCORD_FILES=
MAX__DISCORD_FILE_SIZE=
MAX__UPLOAD_ID_LENGTH=
TARGET__GUILD=
TARGET__CHANNEL=
ACCOUNTS__REGISTRATION_ENABLED=
ACCOUNTS__REQUIRED_FOR_UPLOAD=
MAIL__HOST=
MAIL__PORT=
MAIL__SECURE=
MAIL__SEND_FROM=
MAIL__USER=
MAIL__PASS=

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM node:21-alpine AS base
WORKDIR /usr/src/app
FROM base AS install
RUN mkdir -p /tmp/dev
COPY package.json package-lock.json /tmp/dev/
RUN cd /tmp/dev && npm install
RUN mkdir -p /tmp/prod
COPY package.json package-lock.json /tmp/prod/
RUN cd /tmp/prod && npm install --omit=dev
FROM base AS build
COPY --from=install /tmp/dev/node_modules node_modules
COPY . .
RUN npm run build
FROM base AS app
COPY --from=install /tmp/prod/node_modules node_modules
COPY --from=build /usr/src/app/out out
COPY --from=build /usr/src/app/dist dist
COPY package.json .
COPY assets assets
EXPOSE 3000
ENTRYPOINT [ "node", "./out/server/index.js" ]

10
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,10 @@
services:
monofile:
container_name: "monofile"
image: monofile
build: .
env_file: .env
volumes:
- ".data:/usr/src/app/.data"
ports:
- "3000:3000"

View file

@ -4,8 +4,8 @@ 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 pkg from "../../package.json" assert { type: "json" } import pkg from "../../package.json" assert { type: "json" }
import config from "../../config.json" assert { type: "json" }
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { dirname } from "path" import { dirname } from "path"
@ -23,64 +23,60 @@ program
.description("Quickly run monofile to execute a query or so") .description("Quickly run monofile to execute a query or so")
.version(pkg.version) .version(pkg.version)
program.command("list") program
.command("list")
.alias("ls") .alias("ls")
.description("List files in the database") .description("List files in the database")
.action(() => { .action(() => {
Object.keys(files.files).forEach(e => console.log(e)) Object.keys(files.files).forEach((e) => console.log(e))
}) })
program
program.command("download") .command("download")
.alias("dl") .alias("dl")
.description("Download a file from the database") .description("Download a file from the database")
.argument("<id>", "ID of the file you'd like to download") .argument("<id>", "ID of the file you'd like to download")
.option("-o, --output <path>", 'Folder or filename to output to') .option("-o, --output <path>", "Folder or filename to output to")
.action(async (id, options) => { .action(async (id, options) => {
await new Promise<void>((resolve) => setTimeout(() => resolve(), 1000))
await (new Promise<void>(resolve => setTimeout(() => resolve(), 1000)))
let fp = files.files[id] let fp = files.files[id]
if (!fp) if (!fp) throw `file ${id} not found`
throw `file ${id} not found`
let out = options.output as string || `./` let out = (options.output as string) || `./`
if (fs.existsSync(out) && (await stat(out)).isDirectory()) if (fs.existsSync(out) && (await stat(out)).isDirectory())
out = `${out.replace(/\/+$/, "")}/${fp.filename}` out = `${out.replace(/\/+$/, "")}/${fp.filename}`
let filestream = await files.readFileStream(id) let filestream = await files.readFileStream(id)
let prog=0 let prog = 0
filestream.on("data", dt => { filestream.on("data", (dt) => {
prog+=dt.byteLength prog += dt.byteLength
console.log(`Downloading ${fp.filename}: ${Math.floor(prog/(fp.sizeInBytes??0)*10000)/100}% (${Math.floor(prog/(1024*1024))}MiB/${Math.floor((fp.sizeInBytes??0)/(1024*1024))}MiB)`) console.log(
`Downloading ${fp.filename}: ${Math.floor((prog / (fp.sizeInBytes ?? 0)) * 10000) / 100}% (${Math.floor(prog / (1024 * 1024))}MiB/${Math.floor((fp.sizeInBytes ?? 0) / (1024 * 1024))}MiB)`
)
}) })
filestream.pipe( filestream.pipe(fs.createWriteStream(out))
fs.createWriteStream(out)
)
}) })
program
program.command("upload") .command("upload")
.alias("up") .alias("up")
.description("Upload a file to the instance") .description("Upload a file to the instance")
.argument("<file>", "Path to the file you'd like to upload") .argument("<file>", "Path to the file you'd like to upload")
.option("-id, --fileid <id>", 'Custom file ID to use') .option("-id, --fileid <id>", "Custom file ID to use")
.action(async (file, options) => { .action(async (file, options) => {
await new Promise<void>((resolve) => setTimeout(() => resolve(), 1000))
await (new Promise<void>(resolve => setTimeout(() => resolve(), 1000)))
if (!(fs.existsSync(file) && (await stat(file)).isFile())) if (!(fs.existsSync(file) && (await stat(file)).isFile()))
throw `${file} is not a file` throw `${file} is not a file`
let writable = files.createWriteStream() let writable = files.createWriteStream()
writable writable.setName(basename(file))?.setType("application/octet-stream")
.setName(basename(file))
?.setType("application/octet-stream")
if (options.id) writable.setUploadId(options.id) if (options.id) writable.setUploadId(options.id)
@ -90,7 +86,7 @@ program.command("upload")
console.log(`started: ${file}`) console.log(`started: ${file}`)
writable.on("drain", () => { writable.on("drain", () => {
console.log("Drained"); console.log("Drained")
}) })
writable.on("finish", async () => { writable.on("finish", async () => {
@ -108,11 +104,9 @@ program.command("upload")
writable.on("close", () => { writable.on("close", () => {
console.log("Closed.") console.log("Closed.")
}); })
;(await fs.createReadStream(file)).pipe( ;(await fs.createReadStream(file)).pipe(writable)
writable
)
}) })
program.parse() program.parse()

View file

@ -7,10 +7,10 @@ import Files from "./lib/files.js"
import { getAccount } from "./lib/middleware.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 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 pkg from "../../package.json" assert { type: "json" }
import config from "../../config.json" assert {type:"json"} import config, { ClientConfiguration } from "./lib/config.js"
const app = new Hono() const app = new Hono()
@ -36,10 +36,8 @@ app.get(
// haha... // haha...
app.on(["MOLLER"], "*", async (ctx) => { app.on(["MOLLER"], "*", async (ctx) => {
ctx.header("Content-Type", "image/webp") ctx.header("Content-Type", "image/webp")
return ctx.body( await readFile("./assets/moller.png") ) return ctx.body(await readFile("./assets/moller.png"))
}) })
//app.use(bodyParser.text({limit:(config.maxDiscordFileSize*config.maxDiscordFiles)+1048576,type:["application/json","text/plain"]})) //app.use(bodyParser.text({limit:(config.maxDiscordFileSize*config.maxDiscordFiles)+1048576,type:["application/json","text/plain"]}))
@ -64,10 +62,12 @@ if (config.forceSSL) {
app.get("/server", (ctx) => app.get("/server", (ctx) =>
ctx.json({ ctx.json({
...config,
version: pkg.version, version: pkg.version,
files: Object.keys(files.files).length, files: Object.keys(files.files).length,
}) maxDiscordFiles: config.maxDiscordFiles,
maxDiscordFileSize: config.maxDiscordFileSize,
accounts: config.accounts,
} as ClientConfiguration)
) )
// funcs // funcs
@ -90,25 +90,27 @@ apiRouter.loadAPIMethods().then(() => {
app.get("/:fileId", async (ctx) => app.get("/:fileId", async (ctx) =>
app.fetch( app.fetch(
new Request( new Request(
(new URL( new URL(
`/api/v1/file/${ctx.req.param("fileId")}`, ctx.req.raw.url)).href, `/api/v1/file/${ctx.req.param("fileId")}`,
ctx.req.raw ctx.req.raw.url
).href,
ctx.req.raw
), ),
ctx.env ctx.env
) )
) )
// listen on 3000 or MONOFILE_PORT // listen on 3000 or PORT
// moved here to prevent a crash if someone manages to access monofile before api routes are mounted // moved here to prevent a crash if someone manages to access monofile before api routes are mounted
serve( serve(
{ {
fetch: app.fetch, fetch: app.fetch,
port: Number(process.env.MONOFILE_PORT || 3000), port: Number(process.env.PORT || 3000),
serverOptions: { serverOptions: {
//@ts-ignore //@ts-ignore
requestTimeout: config.requestTimeout requestTimeout: config.requestTimeout,
} },
}, },
(info) => { (info) => {
console.log("Web OK!", info.port, info.address) console.log("Web OK!", info.port, info.address)

View file

@ -2,12 +2,15 @@ import { REST } from "./DiscordRequests.js"
import type { APIMessage } from "discord-api-types/v10" import type { APIMessage } from "discord-api-types/v10"
import FormData from "form-data" import FormData from "form-data"
import { Transform, type Readable } from "node:stream" import { Transform, type Readable } from "node:stream"
import { Configuration } from "../files.js" 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(snowflake: string|number, epoch = DISCORD_EPOCH) { function convertSnowflakeToDate(
snowflake: string | number,
epoch = DISCORD_EPOCH
) {
// Convert snowflake to BigInt to extract timestamp bits // Convert snowflake to BigInt to extract timestamp bits
// https://discord.com/developers/docs/reference#snowflakes // https://discord.com/developers/docs/reference#snowflakes
const milliseconds = BigInt(snowflake) >> 22n const milliseconds = BigInt(snowflake) >> 22n
@ -15,133 +18,164 @@ function convertSnowflakeToDate(snowflake: string|number, epoch = DISCORD_EPOCH)
} }
interface MessageCacheObject { interface MessageCacheObject {
expire: number, expire: number
object: string object: string
} }
export class Client { export class Client {
private readonly token : string private readonly token: string
private readonly rest : REST private readonly rest: REST
private readonly targetChannel : string private readonly targetChannel: string
private readonly config : Configuration private readonly config: Configuration
private messageCache : Map<string, MessageCacheObject> = new Map() private messageCache: Map<string, MessageCacheObject> = new Map()
constructor(token: string, config: Configuration) {
this.token = token
this.rest = new REST(token)
this.targetChannel = config.targetChannel
this.config = config
}
async fetchMessage(id: string, cache: boolean = true) {
if (cache && this.messageCache.has(id)) {
let cachedMessage = this.messageCache.get(id)!
if (cachedMessage.expire >= Date.now()) {
return JSON.parse(cachedMessage.object) as APIMessage
}
}
let message = await (this.rest.fetch(`/channels/${this.targetChannel}/messages/${id}`).then(res=>res.json()) as Promise<APIMessage>)
this.messageCache.set(id, { object: JSON.stringify(message) /* clone object so that removing ids from the array doesn't. yeah */, expire: EXPIRE_AFTER + Date.now() })
return message
}
async deleteMessage(id: string) {
await this.rest.fetch(`/channels/${this.targetChannel}/messages/${id}`, {method: "DELETE"})
this.messageCache.delete(id)
}
// https://discord.com/developers/docs/resources/channel#bulk-delete-messages
// "This endpoint will not delete messages older than 2 weeks" so we need to check each id
async deleteMessages(ids: string[]) {
// Remove bulk deletable messages
let bulkDeletable = ids.filter(e => Date.now()-convertSnowflakeToDate(e).valueOf() < 2 * 7 * 24 * 60 * 60 * 1000)
await this.rest.fetch(`/channels/${this.targetChannel}/messages/bulk-delete`, {
method: "POST",
body: JSON.stringify({messages: bulkDeletable})
})
bulkDeletable.forEach(Map.prototype.delete.bind(this.messageCache))
// everything else, we can do manually...
// there's probably a better way to do this @Jack5079
// fix for me if possible
await Promise.all(ids.map(async e => {
if (Date.now()-convertSnowflakeToDate(e).valueOf() >= 2 * 7 * 24 * 60 * 60 * 1000) {
return await this.deleteMessage(e)
}
}).filter(Boolean)) // filter based on whether or not it's undefined
constructor(token: string, config: Configuration) {
this.token = token
this.rest = new REST(token)
this.targetChannel = config.targetChannel
this.config = config
} }
async send(stream: Readable) { async fetchMessage(id: string, cache: boolean = true) {
if (cache && this.messageCache.has(id)) {
let cachedMessage = this.messageCache.get(id)!
if (cachedMessage.expire >= Date.now()) {
return JSON.parse(cachedMessage.object) as APIMessage
}
}
let bytes_sent = 0 let message = await (this.rest
let file_number = 0 .fetch(`/channels/${this.targetChannel}/messages/${id}`)
let boundary = "-".repeat(20) + Math.random().toString().slice(2) .then((res) => res.json()) as Promise<APIMessage>)
let pushBoundary = (stream: Readable) => this.messageCache.set(id, {
stream.push(`${(file_number++) == 0 ? "" : "\r\n"}--${boundary}\r\nContent-Disposition: form-data; name="files[${file_number}]"; filename="${Math.random().toString().slice(2)}\r\nContent-Type: application/octet-stream\r\n\r\n`) object: JSON.stringify(
let boundPush = (stream: Readable, chunk: Buffer) => { message
let position = 0 ) /* clone object so that removing ids from the array doesn't. yeah */,
console.log(`Chunk length ${chunk.byteLength}`) expire: EXPIRE_AFTER + Date.now(),
})
return message
}
while (position < chunk.byteLength) { async deleteMessage(id: string) {
if ((bytes_sent % this.config.maxDiscordFileSize) == 0) { await this.rest.fetch(
console.log("Progress is 0. Pushing boundary") `/channels/${this.targetChannel}/messages/${id}`,
pushBoundary(stream) { method: "DELETE" }
} )
this.messageCache.delete(id)
}
let capture = Math.min( // https://discord.com/developers/docs/resources/channel#bulk-delete-messages
(this.config.maxDiscordFileSize - (bytes_sent % this.config.maxDiscordFileSize)), // "This endpoint will not delete messages older than 2 weeks" so we need to check each id
chunk.byteLength-position async deleteMessages(ids: string[]) {
) // Remove bulk deletable messages
console.log(`Capturing ${capture} bytes, ${chunk.subarray(position, position+capture).byteLength}`)
stream.push( chunk.subarray(position, position + capture) )
position += capture, bytes_sent += capture
console.log("Chunk progress:", bytes_sent % this.config.maxDiscordFileSize, "B") let bulkDeletable = ids.filter(
} (e) =>
Date.now() - convertSnowflakeToDate(e).valueOf() <
2 * 7 * 24 * 60 * 60 * 1000
)
await this.rest.fetch(
`/channels/${this.targetChannel}/messages/bulk-delete`,
{
method: "POST",
body: JSON.stringify({ messages: bulkDeletable }),
}
)
bulkDeletable.forEach(Map.prototype.delete.bind(this.messageCache))
// everything else, we can do manually...
// there's probably a better way to do this @Jack5079
// fix for me if possible
await Promise.all(
ids
.map(async (e) => {
if (
Date.now() - convertSnowflakeToDate(e).valueOf() >=
2 * 7 * 24 * 60 * 60 * 1000
) {
return await this.deleteMessage(e)
}
})
.filter(Boolean)
) // filter based on whether or not it's undefined
}
} async send(stream: Readable) {
let bytes_sent = 0
let file_number = 0
let boundary = "-".repeat(20) + Math.random().toString().slice(2)
let transformed = new Transform({ let pushBoundary = (stream: Readable) =>
transform(chunk, encoding, callback) { stream.push(
boundPush(this, chunk) `${file_number++ == 0 ? "" : "\r\n"}--${boundary}\r\nContent-Disposition: form-data; name="files[${file_number}]"; filename="${Math.random().toString().slice(2)}\r\nContent-Type: application/octet-stream\r\n\r\n`
callback() )
}, let boundPush = (stream: Readable, chunk: Buffer) => {
flush(callback) { let position = 0
this.push(`\r\n--${boundary}--`) console.log(`Chunk length ${chunk.byteLength}`)
callback()
}
})
let controller = new AbortController() while (position < chunk.byteLength) {
stream.on("error", _ => controller.abort()) if (bytes_sent % this.config.maxDiscordFileSize == 0) {
console.log("Progress is 0. Pushing boundary")
pushBoundary(stream)
}
//pushBoundary(transformed) let capture = Math.min(
stream.pipe(transformed) this.config.maxDiscordFileSize -
(bytes_sent % this.config.maxDiscordFileSize),
chunk.byteLength - position
)
console.log(
`Capturing ${capture} bytes, ${chunk.subarray(position, position + capture).byteLength}`
)
stream.push(chunk.subarray(position, position + capture))
;(position += capture), (bytes_sent += capture)
let returned = await this.rest.fetch(`/channels/${this.targetChannel}/messages`, { console.log(
method: "POST", "Chunk progress:",
body: transformed, bytes_sent % this.config.maxDiscordFileSize,
headers: { "B"
"Content-Type": `multipart/form-data; boundary=${boundary}` )
}, }
signal: controller.signal }
})
let transformed = new Transform({
transform(chunk, encoding, callback) {
boundPush(this, chunk)
callback()
},
flush(callback) {
this.push(`\r\n--${boundary}--`)
callback()
},
})
if (!returned.ok) { let controller = new AbortController()
throw new Error(`[Message creation] ${returned.status} ${returned.statusText}`) stream.on("error", (_) => controller.abort())
}
let response = (await returned.json() as APIMessage) //pushBoundary(transformed)
console.log(JSON.stringify(response, null, 4)) stream.pipe(transformed)
return response
} let returned = await this.rest.fetch(
`/channels/${this.targetChannel}/messages`,
{
method: "POST",
body: transformed,
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
signal: controller.signal,
}
)
if (!returned.ok) {
throw new Error(
`[Message creation] ${returned.status} ${returned.statusText}`
)
}
let response = (await returned.json()) as APIMessage
console.log(JSON.stringify(response, null, 4))
return response
}
} }

72
src/server/lib/config.ts Normal file
View file

@ -0,0 +1,72 @@
import "dotenv/config"
export interface Configuration {
port: number
requestTimeout: number
trustProxy: boolean
forceSSL: boolean
discordToken: string
maxDiscordFiles: number
maxDiscordFileSize: number
maxUploadIdLength: number
targetGuild: string
targetChannel: string
accounts: {
registrationEnabled: boolean
requiredForUpload: boolean
}
mail: {
transport: {
host: string
port: number
secure: boolean
}
send: {
from: string
}
user: string
pass: string
}
}
export interface ClientConfiguration {
version: string
files: number
maxDiscordFiles: number
maxDiscordFileSize: number
accounts: {
registrationEnabled: boolean
requiredForUpload: boolean
}
}
export default {
port: Number(process.env.PORT),
requestTimeout: Number(process.env.REQUEST_TIMEOUT),
trustProxy: process.env.TRUST_PROXY === "true",
forceSSL: process.env.FORCE_SSL === "true",
discordToken: process.env.DISCORD_TOKEN,
maxDiscordFiles: Number(process.env.MAX__DISCORD_FILES),
maxDiscordFileSize: Number(process.env.MAX__DISCORD_FILE_SIZE),
maxUploadIdLength: Number(process.env.MAX__UPLOAD_ID_LENGTH),
targetGuild: process.env.TARGET__GUILD,
targetChannel: process.env.TARGET__CHANNEL,
accounts: {
registrationEnabled:
process.env.ACCOUNTS__REGISTRATION_ENABLED === "true",
requiredForUpload: process.env.ACCOUNTS__REQUIRED_FOR_UPLOAD === "true",
},
mail: {
transport: {
host: process.env.MAIL__HOST,
port: Number(process.env.MAIL__PORT),
secure: process.env.MAIL__SECURE === "true",
},
send: {
from: process.env.MAIL__SEND_FROM,
},
user: process.env.MAIL__USER,
pass: process.env.MAIL__PASS,
},
} as Configuration

View file

@ -3,7 +3,8 @@ 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 } 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 "dotenv/config" import "dotenv/config"
import * as Accounts from "./accounts.js" import * as Accounts from "./accounts.js"
@ -35,7 +36,9 @@ export function generateFileId(length: number = 5) {
* @param conditions * @param conditions
*/ */
function multiAssert(conditions: Map<boolean, { message: string, status: number }>) { function multiAssert(
conditions: Map<boolean, { message: string; status: number }>
) {
for (let [cond, err] of conditions.entries()) { for (let [cond, err] of conditions.entries()) {
if (cond) return err if (cond) return err
} }
@ -44,22 +47,6 @@ function multiAssert(conditions: Map<boolean, { message: string, status: number
export type FileUploadSettings = Partial<Pick<FilePointer, "mime" | "owner">> & export type FileUploadSettings = Partial<Pick<FilePointer, "mime" | "owner">> &
Pick<FilePointer, "mime" | "filename"> & { uploadId?: string } Pick<FilePointer, "mime" | "filename"> & { uploadId?: string }
export interface Configuration {
maxDiscordFiles: number
maxDiscordFileSize: number
targetChannel: string
requestTimeout: number
maxUploadIdLength: number
accounts: {
registrationEnabled: boolean
requiredForUpload: boolean
}
trustProxy: boolean
forceSSL: boolean
}
export interface FilePointer { export interface FilePointer {
filename: string filename: string
mime: string mime: string
@ -80,18 +67,15 @@ export interface StatusCodeError {
} }
export class WebError extends Error { export class WebError extends Error {
readonly statusCode: number = 500 readonly statusCode: number = 500
constructor(status: number, message: string) { constructor(status: number, message: string) {
super(message) super(message)
this.statusCode = status this.statusCode = status
} }
} }
export class ReadStream extends Readable { export class ReadStream extends Readable {
files: Files files: Files
pointer: FilePointer pointer: FilePointer
@ -100,52 +84,60 @@ export class ReadStream extends Readable {
position: number = 0 position: number = 0
ranges: { ranges: {
useRanges: boolean, useRanges: boolean
byteStart: number, byteStart: number
byteEnd: number byteEnd: number
scan_msg_begin: number, scan_msg_begin: number
scan_msg_end: number, scan_msg_end: number
scan_files_begin: number, scan_files_begin: number
scan_files_end: number scan_files_end: number
} }
id: number = Math.random() id: number = Math.random()
aborter?: AbortController aborter?: AbortController
constructor(files: Files, pointer: FilePointer, range?: {start: number, end: number}) { constructor(
files: Files,
pointer: FilePointer,
range?: { start: number; end: number }
) {
super() super()
console.log(this.id, range) console.log(this.id, range)
this.files = files this.files = files
this.pointer = pointer this.pointer = pointer
let useRanges = let useRanges = Boolean(
Boolean(range && pointer.chunkSize && pointer.sizeInBytes) range && pointer.chunkSize && pointer.sizeInBytes
)
this.ranges = { this.ranges = {
useRanges, useRanges,
scan_msg_begin: 0, scan_msg_begin: 0,
scan_msg_end: pointer.messageids.length - 1, scan_msg_end: pointer.messageids.length - 1,
scan_files_begin: scan_files_begin: useRanges
useRanges
? Math.floor(range!.start / pointer.chunkSize!) ? Math.floor(range!.start / pointer.chunkSize!)
: 0, : 0,
scan_files_end: scan_files_end: useRanges
useRanges
? Math.ceil(range!.end / pointer.chunkSize!) - 1 ? Math.ceil(range!.end / pointer.chunkSize!) - 1
: -1, : -1,
byteStart: range?.start || 0, byteStart: range?.start || 0,
byteEnd: range?.end || 0 byteEnd: range?.end || 0,
} }
if (useRanges) if (useRanges)
this.ranges.scan_msg_begin = Math.floor(this.ranges.scan_files_begin / 10), (this.ranges.scan_msg_begin = Math.floor(
this.ranges.scan_msg_end = Math.ceil(this.ranges.scan_files_end / 10), this.ranges.scan_files_begin / 10
this.msgIdx = this.ranges.scan_msg_begin )),
(this.ranges.scan_msg_end = Math.ceil(
this.ranges.scan_files_end / 10
)),
(this.msgIdx = this.ranges.scan_msg_begin)
console.log(this.ranges) console.log(this.ranges)
} }
async _read() {/* async _read() {
/*
console.log("Calling for more data") console.log("Calling for more data")
if (this.busy) return if (this.busy) return
this.busy = true this.busy = true
@ -160,24 +152,32 @@ export class ReadStream extends Readable {
this.pushData() this.pushData()
} }
async _destroy(error: Error | null, callback: (error?: Error | null | undefined) => void): Promise<void> { async _destroy(
if (this.aborter) error: Error | null,
this.aborter.abort() callback: (error?: Error | null | undefined) => void
): Promise<void> {
if (this.aborter) this.aborter.abort()
callback() callback()
} }
async getNextAttachment() { async getNextAttachment() {
// return first in our attachment buffer // return first in our attachment buffer
let ret = this.attachmentBuffer.splice(0,1)[0] let ret = this.attachmentBuffer.splice(0, 1)[0]
if (ret) return ret if (ret) return ret
console.log(this.id, this.msgIdx, this.ranges.scan_msg_end, this.pointer.messageids[this.msgIdx]) console.log(
this.id,
this.msgIdx,
this.ranges.scan_msg_end,
this.pointer.messageids[this.msgIdx]
)
// oh, there's none left. let's fetch a new message, then. // oh, there's none left. let's fetch a new message, then.
if ( if (
!this.pointer.messageids[this.msgIdx] !this.pointer.messageids[this.msgIdx] ||
|| this.msgIdx > this.ranges.scan_msg_end this.msgIdx > this.ranges.scan_msg_end
) return null )
return null
let msg = await this.files.api let msg = await this.files.api
.fetchMessage(this.pointer.messageids[this.msgIdx]) .fetchMessage(this.pointer.messageids[this.msgIdx])
@ -190,95 +190,113 @@ export class ReadStream extends Readable {
let attach = msg.attachments let attach = msg.attachments
console.log(attach) console.log(attach)
this.attachmentBuffer = this.ranges.useRanges ? attach.slice( this.attachmentBuffer = this.ranges.useRanges
this.msgIdx == this.ranges.scan_msg_begin ? attach.slice(
? this.ranges.scan_files_begin - this.ranges.scan_msg_begin * 10 this.msgIdx == this.ranges.scan_msg_begin
: 0, ? this.ranges.scan_files_begin -
this.msgIdx == this.ranges.scan_msg_end this.ranges.scan_msg_begin * 10
? this.ranges.scan_files_end - this.ranges.scan_msg_end * 10 + 1 : 0,
: attach.length this.msgIdx == this.ranges.scan_msg_end
) : attach ? this.ranges.scan_files_end -
this.ranges.scan_msg_end * 10 +
1
: attach.length
)
: attach
console.log(this.attachmentBuffer) console.log(this.attachmentBuffer)
} }
this.msgIdx++ this.msgIdx++
return this.attachmentBuffer.splice(0,1)[0] return this.attachmentBuffer.splice(0, 1)[0]
} }
async getPusherForWebStream(webStream: ReadableStream) { async getPusherForWebStream(webStream: ReadableStream) {
const reader = await webStream.getReader() const reader = await webStream.getReader()
let pushing = false // acts as a debounce just in case let pushing = false // acts as a debounce just in case
// (words of a girl paranoid from writing readfilestream) // (words of a girl paranoid from writing readfilestream)
let pushToStream = this.push.bind(this) let pushToStream = this.push.bind(this)
let stream = this let stream = this
return function() { return function () {
if (pushing) return if (pushing) return
pushing = true pushing = true
return reader.read().catch(e => { return reader
// Probably means an AbortError; whatever it is we'll need to abort .read()
if (webStream.locked) reader.releaseLock() .catch((e) => {
webStream.cancel().catch(e => undefined) // Probably means an AbortError; whatever it is we'll need to abort
if (!stream.destroyed) stream.destroy() if (webStream.locked) reader.releaseLock()
return e webStream.cancel().catch((e) => undefined)
}).then(result => { if (!stream.destroyed) stream.destroy()
if (result instanceof Error || !result) return result return e
})
.then((result) => {
if (result instanceof Error || !result) return result
let pushed let pushed
if (!result.done) { if (!result.done) {
pushing = false pushing = false
pushed = pushToStream(result.value) pushed = pushToStream(result.value)
} }
return {readyForMore: pushed || false, streamDone: result.done } return {
}) readyForMore: pushed || false,
streamDone: result.done,
}
})
} }
} }
async getNextChunk() { async getNextChunk() {
let scanning_chunk = await this.getNextAttachment() let scanning_chunk = await this.getNextAttachment()
console.log(this.id, "Next chunk requested; got attachment", scanning_chunk) console.log(
this.id,
"Next chunk requested; got attachment",
scanning_chunk
)
if (!scanning_chunk) return null if (!scanning_chunk) return null
let { let { byteStart, byteEnd, scan_files_begin, scan_files_end } =
byteStart, byteEnd, scan_files_begin, scan_files_end this.ranges
} = this.ranges
let headers: HeadersInit = let headers: HeadersInit = this.ranges.useRanges
this.ranges.useRanges ? {
? { Range: `bytes=${
Range: `bytes=${ this.position == 0
this.position == 0 ? byteStart -
? byteStart - scan_files_begin * this.pointer.chunkSize! scan_files_begin * this.pointer.chunkSize!
: "0" : "0"
}-${ }-${
this.attachmentBuffer.length == 0 && this.msgIdx == scan_files_end this.attachmentBuffer.length == 0 &&
? byteEnd - scan_files_end * this.pointer.chunkSize! this.msgIdx == scan_files_end
: "" ? byteEnd - scan_files_end * this.pointer.chunkSize!
}`, : ""
} }`,
: {} }
: {}
this.aborter = new AbortController() this.aborter = new AbortController()
let response = await fetch(scanning_chunk.url, {headers, signal: this.aborter.signal}) let response = await fetch(scanning_chunk.url, {
.catch((e: Error) => { headers,
console.error(e) signal: this.aborter.signal,
return {body: e} }).catch((e: Error) => {
}) console.error(e)
return { body: e }
})
this.position++ this.position++
return response.body return response.body
} }
currentPusher?: (() => Promise<{readyForMore: boolean, streamDone: boolean } | void> | undefined) currentPusher?: () =>
| Promise<{ readyForMore: boolean; streamDone: boolean } | void>
| undefined
busy: boolean = false busy: boolean = false
async pushData(): Promise<boolean | undefined> { async pushData(): Promise<boolean | undefined> {
// uh oh, we don't have a currentPusher // uh oh, we don't have a currentPusher
// let's make one then // let's make one then
if (!this.currentPusher) { if (!this.currentPusher) {
@ -292,7 +310,8 @@ export class ReadStream extends Readable {
// or the stream has ended. // or the stream has ended.
// let's destroy the stream // let's destroy the stream
console.log(this.id, "Ending", next) console.log(this.id, "Ending", next)
if (next) this.destroy(next); else this.push(null) if (next) this.destroy(next)
else this.push(null)
return return
} }
} }
@ -304,12 +323,10 @@ export class ReadStream extends Readable {
this.currentPusher = undefined this.currentPusher = undefined
return this.pushData() return this.pushData()
} else return result?.readyForMore } else return result?.readyForMore
} }
} }
export class UploadStream extends Writable { export class UploadStream extends Writable {
uploadId?: string uploadId?: string
name?: string name?: string
mime?: string mime?: string
@ -331,7 +348,11 @@ export class UploadStream extends Writable {
async _write(data: Buffer, encoding: string, callback: () => void) { async _write(data: Buffer, encoding: string, callback: () => void) {
console.log("Write to stream attempted") console.log("Write to stream attempted")
if (this.filled + data.byteLength > (this.files.config.maxDiscordFileSize*this.files.config.maxDiscordFiles)) if (
this.filled + data.byteLength >
this.files.config.maxDiscordFileSize *
this.files.config.maxDiscordFiles
)
return this.destroy(new WebError(413, "maximum file size exceeded")) return this.destroy(new WebError(413, "maximum file size exceeded"))
this.hash.update(data) this.hash.update(data)
@ -343,21 +364,30 @@ export class UploadStream extends Writable {
while (position < data.byteLength) { while (position < data.byteLength) {
let capture = Math.min( let capture = Math.min(
((this.files.config.maxDiscordFileSize*10) - (this.filled % (this.files.config.maxDiscordFileSize*10))), this.files.config.maxDiscordFileSize * 10 -
data.byteLength-position (this.filled % (this.files.config.maxDiscordFileSize * 10)),
data.byteLength - position
)
console.log(
`Capturing ${capture} bytes for megachunk, ${data.subarray(position, position + capture).byteLength}`
) )
console.log(`Capturing ${capture} bytes for megachunk, ${data.subarray(position, position + capture).byteLength}`)
if (!this.current) await this.getNextStream() if (!this.current) await this.getNextStream()
if (!this.current) { if (!this.current) {
this.destroy(new Error("getNextStream called during debounce")); return this.destroy(new Error("getNextStream called during debounce"))
return
} }
readyForMore = this.current.push( data.subarray(position, position+capture) ) readyForMore = this.current.push(
console.log(`pushed ${data.byteLength} byte chunk`); data.subarray(position, position + capture)
position += capture, this.filled += capture )
console.log(`pushed ${data.byteLength} byte chunk`)
;(position += capture), (this.filled += capture)
// message is full, so tell the next run to get a new message // message is full, so tell the next run to get a new message
if (this.filled % (this.files.config.maxDiscordFileSize*10) == 0) { if (
this.filled % (this.files.config.maxDiscordFileSize * 10) ==
0
) {
this.current!.push(null) this.current!.push(null)
this.current = undefined this.current = undefined
} }
@ -369,16 +399,19 @@ export class UploadStream extends Writable {
async _final(callback: (error?: Error | null | undefined) => void) { async _final(callback: (error?: Error | null | undefined) => void) {
if (this.current) { if (this.current) {
this.current.push(null); this.current.push(null)
// i probably dnt need this but whateverrr :3 // i probably dnt need this but whateverrr :3
await new Promise((res,rej) => this.once("debounceReleased", res)) await new Promise((res, rej) => this.once("debounceReleased", res))
} }
callback() callback()
} }
aborted: boolean = false aborted: boolean = false
async _destroy(error: Error | null, callback: (err?: Error|null) => void) { async _destroy(
error: Error | null,
callback: (err?: Error | null) => void
) {
this.error = error || undefined this.error = error || undefined
await this.abort() await this.abort()
callback(error) callback(error)
@ -386,7 +419,7 @@ export class UploadStream extends Writable {
/** /**
* @description Cancel & unlock the file. When destroy() is called with a non-WebError, this is automatically called * @description Cancel & unlock the file. When destroy() is called with a non-WebError, this is automatically called
*/ */
async abort() { async abort() {
if (this.aborted) return if (this.aborted) return
this.aborted = true this.aborted = true
@ -406,8 +439,13 @@ export class UploadStream extends Writable {
async commit() { async commit() {
if (this.errored) throw this.error if (this.errored) throw this.error
if (!this.writableFinished) { if (!this.writableFinished) {
let err = Error("attempted to commit file when the stream was still unfinished") let err = Error(
if (!this.destroyed) {this.destroy(err)}; throw err "attempted to commit file when the stream was still unfinished"
)
if (!this.destroyed) {
this.destroy(err)
}
throw err
} }
// Perform checks // Perform checks
@ -430,19 +468,18 @@ export class UploadStream extends Writable {
messageids: this.messages, messageids: this.messages,
owner: this.owner, owner: this.owner,
sizeInBytes: this.filled, sizeInBytes: this.filled,
visibility: ogf ? ogf.visibility visibility: ogf
: ( ? ogf.visibility
this.owner : this.owner
? Accounts.getFromId(this.owner)?.defaultFileVisibility ? Accounts.getFromId(this.owner)?.defaultFileVisibility
: undefined : undefined,
),
// 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 } : {}),
chunkSize: this.files.config.maxDiscordFileSize, chunkSize: this.files.config.maxDiscordFileSize,
md5: this.hash.digest("hex"), md5: this.hash.digest("hex"),
lastModified: Date.now() lastModified: Date.now(),
} }
delete this.files.locks[this.uploadId!] delete this.files.locks[this.uploadId!]
@ -456,36 +493,57 @@ export class UploadStream extends Writable {
setName(name: string) { setName(name: string) {
if (this.name) if (this.name)
return this.destroy( new WebError(400, "duplicate attempt to set filename") ) return this.destroy(
new WebError(400, "duplicate attempt to set filename")
)
if (name.length > 512) if (name.length > 512)
return this.destroy( new WebError(400, "filename can be a maximum of 512 characters") ) return this.destroy(
new WebError(400, "filename can be a maximum of 512 characters")
)
this.name = name; this.name = name
return this return this
} }
setType(type: string) { setType(type: string) {
if (this.mime) if (this.mime)
return this.destroy( new WebError(400, "duplicate attempt to set mime type") ) return this.destroy(
new WebError(400, "duplicate attempt to set mime type")
)
if (type.length > 256) if (type.length > 256)
return this.destroy( new WebError(400, "mime type can be a maximum of 256 characters") ) return this.destroy(
new WebError(
400,
"mime type can be a maximum of 256 characters"
)
)
this.mime = type; this.mime = type
return this return this
} }
setUploadId(id: string) { setUploadId(id: string) {
if (this.uploadId) if (this.uploadId)
return this.destroy( new WebError(400, "duplicate attempt to set upload ID") ) return this.destroy(
if (!id || id.match(id_check_regex)?.[0] != id new WebError(400, "duplicate attempt to set upload ID")
|| id.length > this.files.config.maxUploadIdLength) )
return this.destroy( new WebError(400, "invalid file ID") ) if (
!id ||
id.match(id_check_regex)?.[0] != id ||
id.length > this.files.config.maxUploadIdLength
)
return this.destroy(new WebError(400, "invalid file ID"))
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"))
if (this.files.locks[id]) if (this.files.locks[id])
return this.destroy( new WebError(409, "a file with this ID is already being uploaded") ) return this.destroy(
new WebError(
409,
"a file with this ID is already being uploaded"
)
)
this.files.locks[id] = true this.files.locks[id] = true
this.uploadId = id this.uploadId = id
@ -498,10 +556,9 @@ export class UploadStream extends Writable {
current?: Readable current?: Readable
messages: string[] = [] messages: string[] = []
private newmessage_debounce : boolean = true private newmessage_debounce: boolean = true
private async startMessage(): Promise<Readable | undefined> { private async startMessage(): Promise<Readable | undefined> {
if (!this.newmessage_debounce) return if (!this.newmessage_debounce) return
this.newmessage_debounce = false this.newmessage_debounce = false
@ -510,24 +567,28 @@ export class UploadStream extends Writable {
let stream = new Readable({ let stream = new Readable({
read() { read() {
// this is stupid but it should work // this is stupid but it should work
console.log("Read called; calling on server to execute callback") console.log(
"Read called; calling on server to execute callback"
)
wrt.emit("exec-callback") wrt.emit("exec-callback")
} },
}) })
stream.pause() stream.pause()
console.log(`Starting a message`) console.log(`Starting a message`)
this.files.api.send(stream).then(message => { this.files.api
this.messages.push(message.id) .send(stream)
console.log(`Sent: ${message.id}`) .then((message) => {
this.newmessage_debounce = true this.messages.push(message.id)
this.emit("debounceReleased") console.log(`Sent: ${message.id}`)
}).catch(e => { this.newmessage_debounce = true
if (!this.errored) this.destroy(e) this.emit("debounceReleased")
}) })
.catch((e) => {
if (!this.errored) this.destroy(e)
})
return stream return stream
} }
private async getNextStream() { private async getNextStream() {
@ -536,12 +597,14 @@ export class UploadStream extends Writable {
if (this.current) return this.current if (this.current) return this.current
else if (this.newmessage_debounce) { else if (this.newmessage_debounce) {
// startmessage.... idk // startmessage.... idk
this.current = await this.startMessage(); this.current = await this.startMessage()
return this.current return this.current
} else { } else {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
console.log("Waiting for debounce to be released...") console.log("Waiting for debounce to be released...")
this.once("debounceReleased", async () => resolve(await this.getNextStream())) this.once("debounceReleased", async () =>
resolve(await this.getNextStream())
)
}) })
} }
} }
@ -557,9 +620,9 @@ export default class Files {
constructor(config: Configuration) { constructor(config: Configuration) {
this.config = config this.config = config
this.api = new API(process.env.TOKEN!, config) this.api = new API(config.discordToken!, config)
readFile(this.data_directory+ "/files.json") readFile(this.data_directory + "/files.json")
.then((buf) => { .then((buf) => {
this.files = JSON.parse(buf.toString() || "{}") this.files = JSON.parse(buf.toString() || "{}")
}) })
@ -592,7 +655,7 @@ export default class Files {
* @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 attachment_sizes = []
@ -604,12 +667,12 @@ export default class Files {
} }
if (!target_file.sizeInBytes) if (!target_file.sizeInBytes)
target_file.sizeInBytes = attachment_sizes.reduce((a, b) => a + b, 0) target_file.sizeInBytes = attachment_sizes.reduce(
(a, b) => a + b,
if (!target_file.chunkSize) 0
target_file.chunkSize = attachment_sizes[0] )
if (!target_file.chunkSize) target_file.chunkSize = attachment_sizes[0]
} }
/** /**
@ -624,7 +687,8 @@ export default class Files {
): Promise<ReadStream> { ): Promise<ReadStream> {
if (this.files[uploadId]) { if (this.files[uploadId]) {
let file = this.files[uploadId] let file = this.files[uploadId]
if (!file.sizeInBytes || !file.chunkSize) await this.update(uploadId) if (!file.sizeInBytes || !file.chunkSize)
await this.update(uploadId)
return new ReadStream(this, file, range) return new ReadStream(this, file, range)
} else { } else {
throw { status: 404, message: "not found" } throw { status: 404, message: "not found" }
@ -648,9 +712,9 @@ export default class Files {
} }
delete this.files[uploadId] delete this.files[uploadId]
if (!noWrite) this.write().catch((err) => { if (!noWrite)
throw err this.write().catch((err) => {
}) throw err
})
} }
} }

View file

@ -1,16 +1,18 @@
import { createTransport } from "nodemailer" import { createTransport } from "nodemailer"
import "dotenv/config" import config from "./config.js"
import config from "../../../config.json" assert {type:"json"}
import { generateFileId } from "./files.js" import { generateFileId } from "./files.js"
let mailConfig = config.mail, const { mail } = config
transport = createTransport({ const transport = createTransport({
...mailConfig.transport, host: mail.transport.host,
auth: { port: mail.transport.port,
user: process.env.MAIL_USER, secure: mail.transport.secure,
pass: process.env.MAIL_PASS, from: mail.send.from,
}, auth: {
}) user: mail.user,
pass: mail.pass,
},
})
/** /**
* @description Sends an email * @description Sends an email
@ -23,7 +25,6 @@ export function sendMail(to: string, subject: string, content: string) {
return transport.sendMail({ return transport.sendMail({
to, to,
subject, subject,
from: mailConfig.send.from,
html: `<span style="font-size:x-large;font-weight:600;">monofile <span style="opacity:0.5">accounts</span></span><br><span style="opacity:0.5">Gain control of your uploads.</span><hr><br>${content html: `<span style="font-size:x-large;font-weight:600;">monofile <span style="opacity:0.5">accounts</span></span><br><span style="opacity:0.5">Gain control of your uploads.</span><hr><br>${content
.replaceAll( .replaceAll(
"<span username>", "<span username>",
@ -37,21 +38,26 @@ export function sendMail(to: string, subject: string, content: string) {
} }
export namespace CodeMgr { export namespace CodeMgr {
export const Intents = ["verifyEmail", "recoverAccount"] as const
export const Intents = [ export type Intent = (typeof Intents)[number]
"verifyEmail",
"recoverAccount"
] as const
export type Intent = typeof Intents[number] export function isIntent(intent: string): intent is Intent {
return intent in Intents
export function isIntent(intent: string): intent is Intent { return intent in Intents } }
export let codes = Object.fromEntries( export let codes = Object.fromEntries(
Intents.map(e => [ Intents.map((e) => [
e, e,
{byId: new Map<string, Code>(), byUser: new Map<string, Code[]>()} {
])) as Record<Intent, { byId: Map<string, Code>, byUser: Map<string, Code[]> }> byId: new Map<string, Code>(),
byUser: new Map<string, Code[]>(),
},
])
) as Record<
Intent,
{ byId: Map<string, Code>; byUser: Map<string, Code[]> }
>
// this is stupid whyd i write this // this is stupid whyd i write this
@ -65,25 +71,30 @@ export namespace CodeMgr {
readonly data: any readonly data: any
constructor(intent: Intent, forUser: string, data?: any, time: number = 15*60*1000) { constructor(
this.for = forUser; intent: Intent,
forUser: string,
data?: any,
time: number = 15 * 60 * 1000
) {
this.for = forUser
this.intent = intent this.intent = intent
this.expiryClear = setTimeout(this.terminate.bind(this), time) this.expiryClear = setTimeout(this.terminate.bind(this), time)
this.data = data this.data = data
codes[intent].byId.set(this.id, this); codes[intent].byId.set(this.id, this)
let byUser = codes[intent].byUser.get(this.for) let byUser = codes[intent].byUser.get(this.for)
if (!byUser) { if (!byUser) {
byUser = [] byUser = []
codes[intent].byUser.set(this.for, byUser); codes[intent].byUser.set(this.for, byUser)
} }
byUser.push(this) byUser.push(this)
} }
terminate() { terminate() {
codes[this.intent].byId.delete(this.id); codes[this.intent].byId.delete(this.id)
let bu = codes[this.intent].byUser.get(this.id)! let bu = codes[this.intent].byUser.get(this.id)!
bu.splice(bu.indexOf(this), 1) bu.splice(bu.indexOf(this), 1)
clearTimeout(this.expiryClear) clearTimeout(this.expiryClear)
@ -93,5 +104,4 @@ export namespace CodeMgr {
return forUser === this.for return forUser === this.for
} }
} }
} }

View file

@ -1,8 +1,8 @@
import { Hono } from "hono" import { Hono } from "hono"
import { readFile, readdir } from "fs/promises"
import Files from "../lib/files.js" import Files from "../lib/files.js"
import {fileURLToPath} from "url" import { fileURLToPath } from "url"
import {dirname} from "path" import { dirname } from "path"
import apis from "./api/apis.js"
const APIDirectory = dirname(fileURLToPath(import.meta.url)) + "/api" const APIDirectory = dirname(fileURLToPath(import.meta.url)) + "/api"
@ -39,9 +39,11 @@ class APIVersion {
async load() { async load() {
for (let _mount of this.definition.mount) { for (let _mount of this.definition.mount) {
let mount = resolveMount(_mount); let mount = resolveMount(_mount)
// no idea if there's a better way to do this but this is all i can think of // no idea if there's a better way to do this but this is all i can think of
let { default: route } = await import(`${this.apiPath}/${mount.file}.js`) as { default: (files: Files, apiRoot: Hono) => Hono } let { default: route } = (await import(
`${this.apiPath}/${mount.file}.js`
)) as { default: (files: Files, apiRoot: Hono) => Hono }
this.root.route(mount.to, route(this.files, this.apiRoot)) this.root.route(mount.to, route(this.files, this.apiRoot))
} }
@ -67,23 +69,12 @@ export default class APIRouter {
let def = new APIVersion(definition, this.files, this.root) let def = new APIVersion(definition, this.files, this.root)
await def.load() await def.load()
this.root.route( this.root.route(definition.baseURL, def.root)
definition.baseURL,
def.root
)
} }
async loadAPIMethods() { async loadAPIMethods() {
let files = await readdir(APIDirectory) for (let api of apis) {
for (let version of files) { await this.mount(api as APIDefinition)
let def = JSON.parse(
(
await readFile(
`${process.cwd()}/src/server/routes/api/${version}/api.json`
)
).toString()
) as APIDefinition
await this.mount(def)
} }
} }
} }

View file

@ -0,0 +1,9 @@
// EXTREME BANDAID SOLUTION
//
// SHOULD BE FIXED IN SVELTEKIT REWRITE
import web from "./web/api.json" assert { type: "json" }
import v0 from "./v0/api.json" assert { type: "json" }
import v1 from "./v1/api.json" assert { type: "json" }
export default [web, v0, v1]

View file

@ -10,7 +10,7 @@ import {
requiresPermissions, requiresPermissions,
} from "../../../lib/middleware.js" } from "../../../lib/middleware.js"
import { accountRatelimit } from "../../../lib/ratelimit.js" import { accountRatelimit } from "../../../lib/ratelimit.js"
import config from "../../../lib/config.js"
import ServeError from "../../../lib/errors.js" import ServeError from "../../../lib/errors.js"
import Files, { import Files, {
FileVisibility, FileVisibility,
@ -26,7 +26,6 @@ export let authRoutes = new Hono<{
} }
}>() }>()
import config from "../../../../../config.json" assert {type:"json"}
authRoutes.all("*", getAccount) authRoutes.all("*", getAccount)
export default function (files: Files) { export default function (files: Files) {
@ -417,10 +416,13 @@ export default function (files: Files) {
pwReset.set(acc.id, { pwReset.set(acc.id, {
code, code,
expiry: setTimeout(() => { expiry: setTimeout(
pwReset.delete(acc?.id || "") () => {
prcIdx.delete(pResetCode?.code || "") pwReset.delete(acc?.id || "")
}, 15 * 60 * 1000), prcIdx.delete(pResetCode?.code || "")
},
15 * 60 * 1000
),
requestedAt: Date.now(), requestedAt: Date.now(),
}) })

View file

@ -1,6 +1,5 @@
// Modules // Modules
import { type Context, Hono } from "hono" import { type Context, Hono } from "hono"
import { getCookie, setCookie } from "hono/cookie" import { getCookie, setCookie } from "hono/cookie"
@ -20,54 +19,83 @@ import {
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 "../../../../../config.json" assert {type:"json"} import Configuration from "../../../lib/config.js"
const router = new Hono<{ const router = new Hono<{
Variables: { Variables: {
account: Accounts.Account, account: Accounts.Account
target: Accounts.Account target: Accounts.Account
} }
}>() }>()
type UserUpdateParameters = Partial<Omit<Accounts.Account, "password"> & { password: string, currentPassword?: string }> type UserUpdateParameters = Partial<
Omit<Accounts.Account, "password"> & {
password: string
currentPassword?: string
}
>
type Message = [200 | 400 | 401 | 403 | 429 | 501, string] type Message = [200 | 400 | 401 | 403 | 429 | 501, string]
// there's probably a less stupid way to do this than `K in keyof Pick<UserUpdateParameters, T>` // there's probably a less stupid way to do this than `K in keyof Pick<UserUpdateParameters, T>`
// @Jack5079 make typings better if possible // @Jack5079 make typings better if possible
type Validator<T extends keyof Partial<Accounts.Account>, ValueNotNull extends boolean> = type Validator<
T extends keyof Partial<Accounts.Account>,
ValueNotNull extends boolean,
> =
/** /**
* @param actor The account performing this action * @param actor The account performing this action
* @param target The target account for this action * @param target The target account for this action
* @param params Changes being patched in by the user * @param params Changes being patched in by the user
*/ */
(actor: Accounts.Account, target: Accounts.Account, params: UserUpdateParameters & (ValueNotNull extends true ? { (
[K in keyof Pick<UserUpdateParameters, T>]-? : UserUpdateParameters[K] actor: Accounts.Account,
} : {}), ctx: Context) => Accounts.Account[T] | Message target: Accounts.Account,
params: UserUpdateParameters &
(ValueNotNull extends true
? {
[K in keyof Pick<
UserUpdateParameters,
T
>]-?: UserUpdateParameters[K]
}
: {}),
ctx: Context
) => Accounts.Account[T] | Message
// this type is so stupid stg // this type is so stupid stg
type ValidatorWithSettings<T extends keyof Partial<Accounts.Account>> = { type ValidatorWithSettings<T extends keyof Partial<Accounts.Account>> =
acceptsNull: true, | {
validator: Validator<T, false> acceptsNull: true
} | { validator: Validator<T, false>
acceptsNull?: false, }
validator: Validator<T, true> | {
} acceptsNull?: false
validator: Validator<T, true>
}
const validators: { const validators: {
[T in keyof Partial<Accounts.Account>]: [T in keyof Partial<Accounts.Account>]:
Validator<T, true> | ValidatorWithSettings<T> | Validator<T, true>
| ValidatorWithSettings<T>
} = { } = {
defaultFileVisibility(actor, target, params) { defaultFileVisibility(actor, target, params) {
if (["public", "private", "anonymous"].includes(params.defaultFileVisibility)) if (
["public", "private", "anonymous"].includes(
params.defaultFileVisibility
)
)
return params.defaultFileVisibility return params.defaultFileVisibility
else return [400, "invalid file visibility"] else return [400, "invalid file visibility"]
}, },
email: { email: {
acceptsNull: true, acceptsNull: true,
validator: (actor, target, params, ctx) => { validator: (actor, target, params, ctx) => {
if (!params.currentPassword // actor on purpose here to allow admins if (
|| (params.currentPassword && Accounts.password.check(actor.id, params.currentPassword))) !params.currentPassword || // actor on purpose here to allow admins
(params.currentPassword &&
Accounts.password.check(actor.id, params.currentPassword))
)
return [401, "current password incorrect"] return [401, "current password incorrect"]
if (!params.email) { if (!params.email) {
@ -81,13 +109,17 @@ const validators: {
return undefined return undefined
} }
if (typeof params.email !== "string") return [400, "email must be string"] if (typeof params.email !== "string")
if (actor.admin) return [400, "email must be string"]
return params.email if (actor.admin) return params.email
// send verification email // send verification email
if ((CodeMgr.codes.verifyEmail.byUser.get(target.id)?.length || 0) >= 2) return [429, "you have too many active codes"] if (
(CodeMgr.codes.verifyEmail.byUser.get(target.id)?.length ||
0) >= 2
)
return [429, "you have too many active codes"]
let code = new CodeMgr.Code("verifyEmail", target.id, params.email) let code = new CodeMgr.Code("verifyEmail", target.id, params.email)
@ -108,81 +140,97 @@ const validators: {
) )
return [200, "please check your inbox"] return [200, "please check your inbox"]
} },
}, },
password(actor, target, params) { password(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 && Accounts.password.check(actor.id, params.currentPassword)) (params.currentPassword &&
) return [401, "current password incorrect"] Accounts.password.check(actor.id, params.currentPassword))
)
return [401, "current password incorrect"]
if ( if (typeof params.password != "string" || params.password.length < 8)
typeof params.password != "string" return [400, "password must be 8 characters or longer"]
|| 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,
`Your login details have been updated`, `Your login details have been updated`,
`<b>Hello there!</b> Your password on your account, <span username>${target.username}</span>, has 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>` : ""}. ` `${actor != target ? ` by <span username>${actor.username}</span>` : ""}. ` +
+ `Please update your saved login details accordingly.` `Please update your saved login details accordingly.`
).catch() ).catch()
} }
return Accounts.password.hash(params.password) return Accounts.password.hash(params.password)
}, },
username(actor, target, params) { username(actor, target, params) {
if (!params.currentPassword // actor on purpose here to allow admins if (
|| (params.currentPassword && Accounts.password.check(actor.id, params.currentPassword))) !params.currentPassword || // actor on purpose here to allow admins
(params.currentPassword &&
Accounts.password.check(actor.id, params.currentPassword))
)
return [401, "current password incorrect"] return [401, "current password incorrect"]
if ( if (
typeof params.username != "string" typeof params.username != "string" ||
|| params.username.length < 3 params.username.length < 3 ||
|| params.username.length > 20 params.username.length > 20
) return [400, "username must be between 3 and 20 characters in length"] )
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) if (
(params.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] !=
params.username
)
return [400, "username has invalid characters"] return [400, "username has invalid characters"]
if (target.email) { if (target.email) {
sendMail( sendMail(
target.email, target.email,
`Your login details have been updated`, `Your login details have been updated`,
`<b>Hello there!</b> Your username on your account, <span username>${target.username}</span>, has 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>. ` `${actor != target ? ` by <span username>${actor.username}</span>` : ""} to <span username>${params.username}</span>. ` +
+ `Please update your saved login details accordingly.` `Please update your saved login details accordingly.`
).catch() ).catch()
} }
return params.username return params.username
}, },
customCSS: { customCSS: {
acceptsNull: true, acceptsNull: true,
validator: (actor, target, params) => { validator: (actor, target, params) => {
if ( if (
!params.customCSS || !params.customCSS ||
(params.customCSS.match(id_check_regex)?.[0] == params.customCSS && (params.customCSS.match(id_check_regex)?.[0] ==
params.customCSS &&
params.customCSS.length <= Configuration.maxUploadIdLength) params.customCSS.length <= Configuration.maxUploadIdLength)
) return params.customCSS )
return params.customCSS
else return [400, "bad file id"] else return [400, "bad file id"]
} },
}, },
embed(actor, target, params) { embed(actor, target, params) {
if (typeof params.embed !== "object") return [400, "must use an object for embed"] if (typeof params.embed !== "object")
return [400, "must use an object for embed"]
if (params.embed.color === undefined) { if (params.embed.color === undefined) {
params.embed.color = target.embed?.color params.embed.color = target.embed?.color
} else if (!((params.embed.color.toLowerCase().match(/[a-f0-9]+/)?.[0] == } else if (
params.embed.color.toLowerCase() && !(
params.embed.color.length == 6) || params.embed.color == null)) return [400, "bad embed color"] (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) { if (params.embed.largeImage === undefined) {
params.embed.largeImage = target.embed?.largeImage params.embed.largeImage = target.embed?.largeImage
@ -194,23 +242,19 @@ const validators: {
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"]
} },
} }
router.use(getAccount) router.use(getAccount)
router.all("/:user", async (ctx, next) => { router.all("/:user", async (ctx, next) => {
let acc = let acc =
ctx.req.param("user") == "me" ctx.req.param("user") == "me"
? ctx.get("account") ? ctx.get("account")
: ( : ctx.req.param("user").startsWith("@")
ctx.req.param("user").startsWith("@") ? Accounts.getFromUsername(ctx.req.param("user").slice(1))
? Accounts.getFromUsername(ctx.req.param("user").slice(1)) : Accounts.getFromId(ctx.req.param("user"))
: Accounts.getFromId(ctx.req.param("user")) if (acc != ctx.get("account") && !ctx.get("account")?.admin)
) return ServeError(ctx, 403, "you cannot manage this user")
if (
acc != ctx.get("account")
&& !ctx.get("account")?.admin
) return ServeError(ctx, 403, "you cannot manage this user")
if (!acc) return ServeError(ctx, 404, "account does not exist") if (!acc) return ServeError(ctx, 404, "account does not exist")
ctx.set("target", acc) ctx.set("target", acc)
@ -219,14 +263,15 @@ router.all("/:user", async (ctx, next) => {
}) })
function isMessage(object: any): object is Message { function isMessage(object: any): object is Message {
return Array.isArray(object) return (
&& object.length == 2 Array.isArray(object) &&
&& typeof object[0] == "number" object.length == 2 &&
&& typeof object[1] == "string" typeof object[0] == "number" &&
typeof object[1] == "string"
)
} }
export default function (files: Files) { export default function (files: Files) {
router.post("/", async (ctx) => { router.post("/", async (ctx) => {
const body = await ctx.req.json() const body = await ctx.req.json()
if (!Configuration.accounts.registrationEnabled) { if (!Configuration.accounts.registrationEnabled) {
@ -282,39 +327,60 @@ export default function (files: Files) {
requiresAccount, requiresAccount,
requiresPermissions("manage"), requiresPermissions("manage"),
async (ctx) => { async (ctx) => {
const body = await ctx.req.json() as UserUpdateParameters const body = (await ctx.req.json()) as UserUpdateParameters
const actor = ctx.get("account")! const actor = ctx.get("account")!
const target = ctx.get("target")! const target = ctx.get("target")!
if (Array.isArray(body)) if (Array.isArray(body)) return ServeError(ctx, 400, "invalid body")
return ServeError(ctx, 400, "invalid body")
let results: ([keyof Accounts.Account, Accounts.Account[keyof Accounts.Account]]|Message)[] = let results: (
(Object.entries(body) | [
.filter(e => e[0] !== "currentPassword") as [keyof Accounts.Account, UserUpdateParameters[keyof Accounts.Account]][]) keyof Accounts.Account,
.map(([x, v]) => { Accounts.Account[keyof Accounts.Account],
if (!validators[x]) ]
return [400, `the ${x} parameter cannot be set or is not a valid parameter`] as Message | Message
)[] = (
Object.entries(body).filter(
(e) => e[0] !== "currentPassword"
) as [
keyof Accounts.Account,
UserUpdateParameters[keyof Accounts.Account],
][]
).map(([x, v]) => {
if (!validators[x])
return [
400,
`the ${x} parameter cannot be set or is not a valid parameter`,
] as Message
let validator = let validator = (
(typeof validators[x] == "object" typeof validators[x] == "object"
? validators[x] ? validators[x]
: { : {
validator: validators[x] as Validator<typeof x, false>, validator: validators[x] as Validator<
acceptsNull: false typeof x,
}) as ValidatorWithSettings<typeof x> false
>,
acceptsNull: false,
}
) as ValidatorWithSettings<typeof x>
if (!validator.acceptsNull && !v) if (!validator.acceptsNull && !v)
return [400, `the ${x} validator does not accept null values`] as Message return [
400,
`the ${x} validator does not accept null values`,
] as Message
return [ return [
x, x,
validator.validator(actor, target, body as any, ctx) validator.validator(actor, target, body as any, ctx),
] as [keyof Accounts.Account, Accounts.Account[keyof Accounts.Account]] ] as [
}) keyof Accounts.Account,
Accounts.Account[keyof Accounts.Account],
]
})
let allMsgs = results.map((v) => { let allMsgs = results.map((v) => {
if (isMessage(v)) if (isMessage(v)) return v
return v
target[v[0]] = v[1] as never // lol target[v[0]] = v[1] as never // lol
return [200, "OK"] as Message return [200, "OK"] as Message
}) })
@ -322,7 +388,9 @@ export default function (files: Files) {
await Accounts.save() await Accounts.save()
if (allMsgs.length == 1) if (allMsgs.length == 1)
return ctx.text(...allMsgs[0]!.reverse() as [Message[1], Message[0]]) // im sorry return ctx.text(
...(allMsgs[0]!.reverse() as [Message[1], Message[0]])
) // im sorry
else return ctx.json(allMsgs) else return ctx.json(allMsgs)
} }
) )
@ -330,11 +398,9 @@ export default function (files: Files) {
router.delete("/:user", requiresAccount, noAPIAccess, async (ctx) => { router.delete("/:user", requiresAccount, noAPIAccess, async (ctx) => {
let acc = ctx.get("target") let acc = ctx.get("target")
auth.AuthTokens.filter((e) => e.account == acc?.id).forEach( auth.AuthTokens.filter((e) => e.account == acc?.id).forEach((token) => {
(token) => { auth.invalidate(token.token)
auth.invalidate(token.token) })
}
)
await Accounts.deleteAccount(acc.id) await Accounts.deleteAccount(acc.id)
@ -342,9 +408,7 @@ export default function (files: Files) {
await sendMail( await sendMail(
acc.email, acc.email,
"Notice of account deletion", "Notice of account deletion",
`Your account, <span username>${ `Your account, <span username>${acc.username}</span>, has been removed. Thank you for using monofile.`
acc.username
}</span>, has been removed. Thank you for using monofile.`
).catch() ).catch()
return ctx.text("OK") return ctx.text("OK")
} }
@ -364,19 +428,18 @@ export default function (files: Files) {
auth.getPermissions(sessionToken)?.includes("email") auth.getPermissions(sessionToken)?.includes("email")
? acc.email ? acc.email
: undefined, : undefined,
activeSessions: auth.AuthTokens.filter( activeSessions: auth.AuthTokens.filter(
(e) => (e) =>
e.type != "App" && e.type != "App" &&
e.account == acc.id && e.account == acc.id &&
(e.expire > Date.now() || !e.expire) (e.expire > Date.now() || !e.expire)
).length, ).length,
}) })
}) })
router.get("/css", async (ctx) => { router.get("/css", async (ctx) => {
let acc = ctx.get('account') let acc = ctx.get("account")
if (acc?.customCSS) if (acc?.customCSS) return ctx.redirect(`/file/${acc.customCSS}`)
return ctx.redirect(`/file/${acc.customCSS}`)
else return ctx.text("") else return ctx.text("")
}) })

View file

@ -1,8 +1,5 @@
{ {
"name": "web", "name": "web",
"baseURL": "/", "baseURL": "/",
"mount": [ "mount": [{ "file": "preview", "to": "/download" }, "go"]
{ "file": "preview", "to": "/download" },
"go"
]
} }

View file

@ -2,39 +2,53 @@ import { writable } from "svelte/store"
//import type Pulldown from "./pulldowns/Pulldown.svelte" //import type Pulldown from "./pulldowns/Pulldown.svelte"
import type { SvelteComponent } from "svelte" import type { SvelteComponent } from "svelte"
import type { Account } from "../../server/lib/accounts" import type { Account } from "../../server/lib/accounts"
import type cfg from "../../../config.json" import type { ClientConfiguration } from "../../server/lib/config"
import type { FilePointer } from "../../server/lib/files" import type { FilePointer } from "../../server/lib/files"
export let refreshNeeded = writable(false) export let refreshNeeded = writable(false)
export let pulldownManager = writable<SvelteComponent>() export let pulldownManager = writable<SvelteComponent>()
export let account = writable<Account & {sessionCount: number, sessionExpires: number}|undefined>() export let account = writable<
export let serverStats = writable<typeof cfg & {version: string, files: number} | undefined>() (Account & { sessionCount: number; sessionExpires: number }) | undefined
export let files = writable<(FilePointer & {id:string})[]>([]) >()
export let serverStats = writable<ClientConfiguration | undefined>()
export let files = writable<(FilePointer & { id: string })[]>([])
export let fetchAccountData = function() { export let fetchAccountData = function () {
fetch("/auth/me").then(async (response) => { fetch("/auth/me")
if (response.status == 200) { .then(async (response) => {
account.set(await response.json()) if (response.status == 200) {
} else { account.set(await response.json())
account.set(undefined) } else {
} account.set(undefined)
}).catch((err) => { console.error(err) }) }
})
.catch((err) => {
console.error(err)
})
} }
export let fetchFilePointers = function() { export let fetchFilePointers = function () {
fetch("/files/list", { cache: "no-cache" }).then(async (response) => { fetch("/files/list", { cache: "no-cache" })
if (response.status == 200) { .then(async (response) => {
files.set(await response.json()) if (response.status == 200) {
} else { files.set(await response.json())
files.set([]) } else {
} files.set([])
}).catch((err) => { console.error(err) }) }
})
.catch((err) => {
console.error(err)
})
} }
export let refresh_stats = () => { export let refresh_stats = () => {
fetch("/server").then(async (data) => { fetch("/server")
serverStats.set(await data.json()) .then(async (data) => {
}).catch((err) => { console.error(err) }) serverStats.set(await data.json())
})
.catch((err) => {
console.error(err)
})
} }
fetchAccountData() fetchAccountData()

View file

@ -1,12 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"rootDir": ".", "rootDir": ".",
"outDir": ".", "outDir": ".",
"resolveJsonModule": true, "resolveJsonModule": true,
"composite": true, "composite": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"files": [ "files": ["package.json"]
"package.json", "config.json"
]
} }