This commit is contained in:
unlinkability 2024-06-25 15:11:41 -05:00 committed by GitHub
commit 40e7350390
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 8409 additions and 5340 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__CHANNEL=
ACCOUNTS__REGISTRATION_ENABLED=
ACCOUNTS__REQUIRED_FOR_UPLOAD=
MAIL__HOST=
MAIL__PORT=
MAIL__SECURE=
MAIL__SEND_FROM=
MAIL__USER=
MAIL__PASS=
JWT_SECRET=

4
.gitignore vendored
View file

@ -1,4 +1,6 @@
node_modules
.env
.data
out
out
dist
tsconfig.tsbuildinfo

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"useTabs": false,
"semi": false,
"trailingComma": "es5",
"tabWidth": 4
}

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" ]

View file

@ -26,7 +26,6 @@ Invite your bot to a server, and create a new `config.json` in the project root:
{
"maxDiscordFiles": 20,
"maxDiscordFileSize": 26214400,
"targetGuild": "1024080490677936248",
"targetChannel": "1024080525993971913",
"requestTimeout":120000,
"maxUploadIdLength":30,
@ -72,3 +71,4 @@ Although we believe monofile is not against Discord's developer terms of service
Code written by Etcetera is currently licensed under [Unlicense](./LICENSE).
Icons under `/assets/icons` were created by Microsoft, and as such are licensed under [different terms](./assets/icons/README.md) (MIT).

View file

@ -1 +1,7 @@
<!--
Excuse me? Are you British?
Oh no... Oh no, no, no, no, no!
Hatsune Miku does not talk to British people!
The only pounds I need are me pounding your mom!
-->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#DDD" d="M10.985 3.165a1 1 0 0 0-1.973-.33l-.86 5.163L3.998 8a1 1 0 1 0 .002 2l3.817-.002-.667 4L3 14a1 1 0 1 0 0 2l3.817-.002-.807 4.838a1 1 0 1 0 1.973.329l.862-5.167 4.975-.003-.806 4.84a1 1 0 1 0 1.972.33l.862-5.17L20 15.992a1 1 0 0 0 0-2l-3.819.001.667-4.001L21 9.99a1 1 0 0 0 0-2l-3.818.002.804-4.827a1 1 0 1 0-1.972-.33l-.86 5.159-4.975.003.806-4.832Zm-1.14 6.832 4.976-.003-.667 4.001-4.976.002.667-4Z"/></svg>

Before

Width:  |  Height:  |  Size: 525 B

After

Width:  |  Height:  |  Size: 706 B

BIN
assets/moller.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

View file

@ -1,11 +1,10 @@
{
"maxDiscordFiles": 20,
"maxDiscordFileSize": 26214400,
"targetGuild": "1024080490677936248",
"maxDiscordFiles": 1000,
"maxDiscordFileSize": 10485760,
"targetGuild": "906767804575928390",
"targetChannel": "1024080525993971913",
"requestTimeout":120000,
"maxUploadIdLength":30,
"requestTimeout": 3600000,
"maxUploadIdLength": 30,
"accounts": {
"registrationEnabled": true,
"requiredForUpload": false
@ -21,7 +20,6 @@
"from": "mono@fyle.uk"
}
},
"trustProxy": true,
"forceSSL": true
}
"forceSSL": false
}

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"

4351
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,54 @@
{
"name": "monofile",
"version": "2.0.0-dev",
"description": "Discord-based file sharing",
"main": "index.js",
"scripts": {
"start": "node ./out/server/index.js",
"build": "tsc\nsass src/style:out/style\nrollup -c",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "Etcetera (https://cetera.uk)",
"license": "Unlicense",
"engines": {
"node": ">=v16.11"
},
"dependencies": {
"@types/body-parser": "^1.19.2",
"@types/express": "^4.17.14",
"@types/multer": "^1.4.7",
"@types/nodemailer": "^6.4.8",
"axios": "^0.27.2",
"body-parser": "^1.20.0",
"bytes": "^3.1.2",
"cookie-parser": "^1.4.6",
"discord.js": "^14.7.1",
"dotenv": "^16.0.2",
"express": "^4.18.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.3",
"typescript": "^4.8.3"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.0.1",
"@types/bytes": "^3.1.1",
"@types/cookie-parser": "^1.4.3",
"rollup": "^3.11.0",
"rollup-plugin-svelte": "^7.1.0",
"sass": "^1.57.1",
"svelte": "^3.55.1"
}
}
{
"name": "monofile",
"version": "2.0.0-dev",
"description": "Discord-based file sharing",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node ./out/server/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "vite",
"build": "tsc --build src/server && vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "Etcetera (https://cetera.uk)",
"license": "Unlicense",
"engines": {
"node": ">=v21"
},
"dependencies": {
"@hono/node-server": "^1.8.2",
"axios": "^0.27.2",
"bytes": "^3.1.2",
"commander": "^11.1.0",
"dotenv": "^16.0.2",
"formidable": "^3.5.1",
"hono": "^4.0.10",
"jose": "^5.2.4",
"multer": "^1.4.5-lts.1",
"node-fetch": "^3.3.2",
"nodemailer": "^6.9.3",
"range-parser": "^1.2.1",
"zod": "^3.23.5"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.4.6",
"@tsconfig/svelte": "^4.0.1",
"@types/body-parser": "^1.19.2",
"@types/bytes": "^3.1.1",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.14",
"@types/formidable": "^3.4.5",
"@types/multer": "^1.4.7",
"@types/nodemailer": "^6.4.8",
"@types/range-parser": "^1.2.6",
"discord-api-types": "^0.37.61",
"sass": "^1.57.1",
"svelte": "^3.55.1",
"svelte-preprocess": "^5.1.3",
"tslib": "^2.6.2",
"typescript": "^5.4.5",
"vite": "^4.5.0"
}
}

View file

@ -1,55 +0,0 @@
<!--
for some reason (don't know why)
certain things break
when not in quirks mode
so i'm not adding in the
doctype html
-->
<html lang="en">
<head>
<link
rel="stylesheet"
href="/static/style/app.css"
>
<link
rel="apple-touch-icon"
href="/static/assets/apple-touch-icon.png"
>
<link
rel="icon"
type="image/svg"
href="/static/assets/icons/icon.svg"
>
<link
rel="stylesheet"
href="/auth/customCSS"
>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=0"
>
<script type="module" src="/static/js/index.js"></script>
<title>monofile</title>
<meta name="title" content="monofile">
<meta name="description" content="The open-source Discord-based file sharing service">
<meta name="theme-color" content="rgb(30, 33, 36)">
<meta name="twitter:card" content="summary_large_image">
<meta name="image" content="/static/assets/banner.png">
<meta name="og:image" content="/static/assets/banner.png">
</head>
<body>
</body>
</html>

1459
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,17 +0,0 @@
import svelte from 'rollup-plugin-svelte'
import resolve from "@rollup/plugin-node-resolve"
export default [
{
input: "src/client/index.js",
output: {
file: 'out/client/index.js',
format: 'esm',
sourcemap:true
},
plugins: [
resolve({ browser: true }),
svelte({})
]
}
]

View file

@ -1,5 +0,0 @@
import App from "../svelte/App.svelte"
new App({
target: document.body
})

View file

@ -14,12 +14,12 @@
<link
rel="stylesheet"
href="/static/style/downloads.css"
href="./style/downloads.scss"
>
<link
rel="stylesheet"
href="/auth/customCSS"
href="/api/v1/account/me/css"
>
<link
@ -51,4 +51,4 @@
</div>
</body>
</html>
</html>

View file

@ -6,7 +6,7 @@
<link
rel="stylesheet"
href="/static/style/error.css"
href="./style/error.scss"
>
<link
@ -17,7 +17,7 @@
<link
rel="stylesheet"
href="/auth/customCSS"
href="/api/v1/account/me/css"
>
<meta
@ -38,4 +38,4 @@
</p>
</body>
</html>
</html>

38
src/index.html Normal file
View file

@ -0,0 +1,38 @@
<!--
for some reason (don't know why)
certain things break
when not in quirks mode
so i'm not adding in the
doctype html
-->
<html lang="en">
<head>
<link rel="stylesheet" href="./style/app.scss" />
<link
rel="icon"
type="image/svg"
href="/static/assets/icons/icon.svg"
/>
<link rel="stylesheet" href="/api/v1/account/me/css" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=0"
/>
<script type="module" src="./svelte/index.ts"></script>
<title>monofile</title>
<meta name="title" content="monofile" />
<meta
name="description"
content="The open-source Discord-based file sharing service"
/>
<meta name="theme-color" content="rgb(30, 33, 36)" />
</head>
<body></body>
</html>

View file

@ -1,163 +1,133 @@
import cookieParser from "cookie-parser";
import { IntentsBitField, Client } from "discord.js"
import express from "express"
import { serve } from "@hono/node-server"
import { serveStatic } from "@hono/node-server/serve-static"
import { Hono } from "hono"
import fs from "fs"
import bytes from "bytes";
import { readFile } from "fs/promises"
import Files from "./lib/files.js"
import APIRouter from "./routes/api.js"
import { fileURLToPath } from "url"
import { dirname } from "path"
import config from "./lib/config.js"
import { dbs } from "./lib/dbfile.js"
import ServeError from "./lib/errors"
import Files from "./lib/files"
import * as auth from "./lib/auth"
import * as Accounts from "./lib/accounts"
const app = new Hono({strict: false})
import * as authRoutes from "./routes/authRoutes";
import * as fileApiRoutes from "./routes/fileApiRoutes";
import * as adminRoutes from "./routes/adminRoutes";
import * as primaryApi from "./routes/primaryApi";
import { getAccount } from "./lib/middleware";
app.get(
"/static/assets/*",
serveStatic({
rewriteRequestPath: (path) => {
return path.replace("/static/assets", "/assets")
},
})
)
app.get(
"/static/vite/*",
serveStatic({
rewriteRequestPath: (path) => {
return path.replace("/static/vite", "/dist/static/vite")
},
})
)
require("dotenv").config()
// respond to the MOLLER method
// get it?
// haha...
let pkg = require(`${process.cwd()}/package.json`)
let app = express()
let config = require(`${process.cwd()}/config.json`)
app.use("/static/assets",express.static("assets"))
app.use("/static/style",express.static("out/style"))
app.use("/static/js",express.static("out/client"))
app.on(["MOLLER"], "*", async (ctx) => {
ctx.header("Content-Type", "image/webp")
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(cookieParser())
// check for ssl, if not redirect
if (config.trustProxy) app.enable("trust proxy")
if (config.trustProxy) {
// app.enable("trust proxy")
}
if (config.forceSSL) {
app.use((req,res,next) => {
if (req.protocol == "http") res.redirect(`https://${req.get("host")}${req.originalUrl}`)
else next()
app.use(async (ctx, next) => {
if (new URL(ctx.req.url).protocol == "http") {
return ctx.redirect(
`https://${ctx.req.header("host")}${
new URL(ctx.req.url).pathname
}`
)
} else {
return next()
}
})
}
app.get("/server",(req,res) => {
res.send(JSON.stringify({
...config,
version:pkg.version,
files:Object.keys(files.files).length
}))
})
app
.use("/auth",authRoutes.authRoutes)
.use("/admin",adminRoutes.adminRoutes)
.use("/files", fileApiRoutes.fileApiRoutes)
.use(primaryApi.primaryApi)
// funcs
// init data
if (!fs.existsSync(__dirname+"/../.data/")) fs.mkdirSync(__dirname+"/../.data/")
// discord
let files = new Files(config)
let client = new Client({intents:[
IntentsBitField.Flags.GuildMessages,
IntentsBitField.Flags.MessageContent
],rest:{timeout:config.requestTimeout}})
// ts screams at me if i don't
// use a function here.
// i'm inflight so
// i'm too lazy to figure this out
const apiRouter = new APIRouter(files)
apiRouter.loadAPIMethods().then(async () =>
Promise.all(
Object.values(dbs)
.map(e => e.readInProgress)
.filter(e => Boolean(e))
)
).then(() => {
app.route("/", apiRouter.root)
console.log("API OK!")
let files = new Files(client,config)
// 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
)
)
authRoutes.setFilesObj(files)
adminRoutes.setFilesObj(files)
fileApiRoutes.setFilesObj(files)
primaryApi.setFilesObj(files)
app.get("/:fileId", async (ctx) =>
app.fetch(
new Request(
new URL(
`/api/v1/file/${ctx.req.param("fileId")}`,
ctx.req.raw.url
).href,
ctx.req.raw
),
ctx.env
)
)
// routes (could probably make these use routers)
// listen on 3000 or PORT
// moved here to prevent a crash if someone manages to access monofile before api routes are mounted
serve(
{
fetch: app.fetch,
port: Number(process.env.PORT || 3000),
serverOptions: {
//@ts-ignore
requestTimeout: config.requestTimeout,
},
},
(info) => {
console.log("Web OK!", info.port, info.address)
}
)
})
// index, clone
app.get("/", function(req,res) {
res.sendFile(process.cwd()+"/pages/index.html")
})
// serve download page
app.get("/download/:fileId", getAccount, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (files.getFilePointer(req.params.fileId)) {
let file = files.getFilePointer(req.params.fileId)
if (file.visibility == "private" && acc?.id != file.owner) {
ServeError(res,403,"you do not own this file")
return
}
fs.readFile(process.cwd()+"/pages/download.html",(err,buf) => {
let fileOwner = file.owner ? Accounts.getFromId(file.owner) : undefined;
if (err) {res.sendStatus(500);console.log(err);return}
res.send(
buf.toString()
.replace(/\$FileId/g,req.params.fileId)
.replace(/\$Version/g,pkg.version)
.replace(/\$FileSize/g,file.sizeInBytes ? bytes(file.sizeInBytes) : "[File size unknown]")
.replace(/\$FileName/g,
file.filename
.replace(/\&/g,"&amp;")
.replace(/\</g,"&lt;")
.replace(/\>/g,"&gt;")
)
.replace(/\<\!\-\-metaTags\-\-\>/g,
(
file.mime.startsWith("image/")
? `<meta name="og:image" content="https://${req.headers.host}/file/${req.params.fileId}" />`
: (
file.mime.startsWith("video/")
? (
`<meta property="og:video:url" content="https://${req.headers.host}/cpt/${req.params.fileId}/video.${file.mime.split("/")[1] == "quicktime" ? "mov" : file.mime.split("/")[1]}" />
<meta property="og:video:secure_url" content="https://${req.headers.host}/cpt/${req.params.fileId}/video.${file.mime.split("/")[1] == "quicktime" ? "mov" : file.mime.split("/")[1]}" />
<meta property="og:type" content="video.other">
<!-- honestly probably good enough for now -->
<meta property="twitter:image" content="0">`
// quick lazy fix as a fallback
// maybe i'll improve this later, but probably not.
+ ((file.sizeInBytes||0) >= 26214400 ? `
<meta property="og:video:width" content="1280">
<meta property="og:video:height" content="720">` : "")
)
: ""
)
)
+ (
fileOwner?.embed?.largeImage && file.visibility!="anonymous" && file.mime.startsWith("image/")
? `<meta name="twitter:card" content="summary_large_image">`
: ""
)
+ `\n<meta name="theme-color" content="${fileOwner?.embed?.color && file.visibility!="anonymous" && (req.headers["user-agent"]||"").includes("Discordbot") ? `#${fileOwner.embed.color}` : "rgb(30, 33, 36)"}">`
)
.replace(/\<\!\-\-preview\-\-\>/g,
file.mime.startsWith("image/")
? `<div style="min-height:10px"></div><img src="/file/${req.params.fileId}" />`
: (
file.mime.startsWith("video/")
? `<div style="min-height:10px"></div><video src="/file/${req.params.fileId}" controls></video>`
: (
file.mime.startsWith("audio/")
? `<div style="min-height:10px"></div><audio src="/file/${req.params.fileId}" controls></audio>`
: ""
)
)
)
.replace(/\$Uploader/g,!file.owner||file.visibility=="anonymous" ? "Anonymous" : `@${fileOwner?.username || "Deleted User"}`)
)
})
} else {
ServeError(res,404,"file not found")
}
})
app.get("/", async (ctx) =>
ctx.html(
await fs.promises.readFile(process.cwd() + "/dist/index.html", "utf-8")
)
)
/*
routes should be in this order:
@ -168,10 +138,4 @@ app.get("/download/:fileId", getAccount, (req,res) => {
file serving
*/
// listen on 3000 or MONOFILE_PORT
app.listen(process.env.MONOFILE_PORT || 3000,function() {
console.log("Web OK!")
})
client.login(process.env.TOKEN)
export default app

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,180 @@
import { REST } from "./DiscordRequests.js"
import type { APIMessage } from "discord-api-types/v10"
import { Transform, type Readable } from "node:stream"
import type { Configuration } from "../config.js"
const EXPIRE_AFTER = 20 * 60 * 1000
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
export function convertSnowflakeToDate(
snowflake: string | number,
epoch = DISCORD_EPOCH
) {
// Convert snowflake to BigInt to extract timestamp bits
// https://discord.com/developers/docs/reference#snowflakes
const milliseconds = BigInt(snowflake) >> 22n
return new Date(Number(milliseconds) + epoch)
}
interface MessageCacheObject {
expire: number
object: string
}
export class Client {
private readonly token: string
private readonly rest: REST
private readonly targetChannel: string
private readonly config: Configuration
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
}
async send(stream: Readable) {
let bytes_sent = 0
let file_number = 0
let boundary = "-".repeat(20) + Math.random().toString().slice(2)
let pushBoundary = (stream: Readable) =>
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`
)
let boundPush = (stream: Readable, chunk: Buffer) => {
let position = 0
console.log(`Chunk length ${chunk.byteLength}`)
while (position < chunk.byteLength) {
if (bytes_sent % this.config.maxDiscordFileSize == 0) {
console.log("Progress is 0. Pushing boundary")
pushBoundary(stream)
}
let capture = Math.min(
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)
console.log(
"Chunk progress:",
bytes_sent % this.config.maxDiscordFileSize,
"B"
)
}
}
let transformed = new Transform({
transform(chunk, encoding, callback) {
boundPush(this, chunk)
callback()
},
flush(callback) {
this.push(`\r\n--${boundary}--`)
callback()
},
})
let controller = new AbortController()
stream.on("error", (_) => controller.abort())
//pushBoundary(transformed)
stream.pipe(transformed)
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
}
}

View file

@ -1,31 +1,17 @@
import crypto from "crypto"
import * as auth from "./auth";
import * as auth from "./auth.js";
import { readFile, writeFile } from "fs/promises"
import { FileVisibility } from "./files";
import { FileVisibility } from "./files.js";
import { AccountSchemas } from "./schemas/index.js";
import { z } from "zod"
import DbFile from "./dbfile.js";
// this is probably horrible
// but i don't even care anymore
export let Accounts: Account[] = []
export let Db = new DbFile<Account[]>("accounts",[])
export interface Account {
id : string
username : string
email? : string
password : {
hash : string
salt : string
}
files : string[]
admin : boolean
defaultFileVisibility : FileVisibility
customCSS? : string
embed? : {
color? : string
largeImage? : boolean
}
}
export type Account = z.infer<typeof AccountSchemas.Account>
/**
* @description Create a new account.
@ -35,23 +21,21 @@ export interface Account {
* @returns A Promise which returns the new account's ID
*/
export function create(username:string,pwd:string,admin:boolean=false):Promise<string> {
return new Promise((resolve,reject) => {
let accId = crypto.randomBytes(12).toString("hex")
export async function create(username:string,pwd:string,admin:boolean=false):Promise<Account> {
let acc: Account = {
id: crypto.randomUUID(),
username: username,
password: password.hash(pwd),
files: [],
admin: admin,
defaultFileVisibility: "public",
settings: AccountSchemas.Settings.User.parse({})
}
Accounts.push(
{
id: accId,
username: username,
password: password.hash(pwd),
files: [],
admin: admin,
defaultFileVisibility: "public"
}
)
save().then(() => resolve(accId))
})
Db.data.push(acc)
await Db.save()
return acc
}
/**
@ -60,7 +44,7 @@ export function create(username:string,pwd:string,admin:boolean=false):Promise<s
* @returns An Account, if it exists
*/
export function getFromUsername(username:string) {
return Accounts.find(e => e.username == username)
return Db.data.find(e => e.username == username)
}
/**
@ -69,7 +53,7 @@ export function getFromUsername(username:string) {
* @returns An Account, if it exists
*/
export function getFromId(id:string) {
return Accounts.find(e => e.id == id)
return Db.data.find(e => e.id == id)
}
/**
@ -88,8 +72,8 @@ export function getFromToken(token:string) {
* @param id The target account's ID
*/
export function deleteAccount(id:string) {
Accounts.splice(Accounts.findIndex(e => e.id == id),1)
return save()
Db.data.splice(Db.data.findIndex(e => e.id == id),1)
return Db.save()
}
export namespace password {
@ -117,11 +101,11 @@ export namespace password {
*/
export function set(id:string,password:string) {
let acc = Accounts.find(e => e.id == id)
let acc = Db.data.find(e => e.id == id)
if (!acc) return
acc.password = hash(password)
return save()
return Db.save()
}
@ -131,7 +115,7 @@ export namespace password {
* @param password Password to check
*/
export function check(id:string,password:string) {
let acc = Accounts.find(e => e.id == id)
let acc = Db.data.find(e => e.id == id)
if (!acc) return
return acc.password.hash == hash(password,acc.password.salt).hash
@ -145,16 +129,16 @@ export namespace files {
* @param fileId The target file's ID
* @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
// { x:true }
// for faster lookups? not sure if it would be faster
let acc = Accounts.find(e => e.id == accountId)
let acc = Db.data.find(e => e.id == accountId)
if (!acc) return
if (acc.files.find(e => e == fileId)) return
acc.files.push(fileId)
return save()
if (!noWrite) return Db.save()
}
/**
@ -165,31 +149,29 @@ export namespace files {
* @returns A Promise which resolves when accounts.json finishes writing, if `noWrite` is `false`
*/
export function deindex(accountId:string,fileId:string, noWrite:boolean=false) {
let acc = Accounts.find(e => e.id == accountId)
let acc = Db.data.find(e => e.id == accountId)
if (!acc) return
let fi = acc.files.findIndex(e => e == fileId)
if (fi >= 0) {
acc.files.splice(fi,1)
if (!noWrite) return save()
if (!noWrite) return Db.save()
}
}
}
/**
* @description Saves accounts.json
* @returns A promise which resolves when accounts.json finishes writing
*/
export function save() {
return writeFile(`${process.cwd()}/.data/accounts.json`,JSON.stringify(Accounts))
.catch((err) => console.error(err))
export type AccountResolvable = Account | string | `@${string}`
export function resolve(obj: AccountResolvable) {
return typeof obj == "object"
? obj
: obj.startsWith("@")
? getFromUsername(obj.slice(1))
: getFromId(obj)
}
readFile(`${process.cwd()}/.data/accounts.json`)
.then((buf) => {
Accounts = JSON.parse(buf.toString())
}).catch(err => console.error(err))
.finally(() => {
if (!Accounts.find(e => e.admin)) {
Db.read()
.then(() => {
if (!Db.data.find(e => e.admin)) {
create("admin","admin",true)
}
})

59
src/server/lib/apply.ts Normal file
View file

@ -0,0 +1,59 @@
import type Files from "./files.js"
import type { FilePointer } from "./files.js"
import * as Accounts from "./accounts.js"
import { FileSchemas } from "./schemas/index.js"
export type Update = Pick<FilePointer, "visibility" | "filename" | "tag">
& {
owner: string | null,
id: string
}
export function applyTagMask(tags: string[], mask: Record<string, boolean>) {
return Object.entries(Object.assign(
Object.fromEntries(tags.map(e => [e, true])),
mask
)).filter(e => e[1]).map(e => e[0])
}
export const operations : Exclude<({
[K in keyof Update]: [K,
((files: Files, passed: Update[K], id: string, file: FilePointer) => void)
| true
]
})[keyof Update], undefined>[] = [
["filename", true],
["visibility", true],
["tag", true],
["owner", (files: Files, owner: string|null, id: string, file: FilePointer) => {
files.chown(id, owner || undefined, true)
return
}],
["id", (files: Files, newId: string, oldId: string, file: FilePointer) => {
files.mv(oldId, newId, true)
return
}]
]
export default function apply(
files: Files,
uploadId: string,
source: Partial<Update>,
noWrite: boolean = false
) {
let file = files.db.data[uploadId]
let issues = operations.map(([k, v]) => {
if (source[k] === undefined) return
if (v == true)
//@ts-ignore SHUTUPSHUTUPSHUTUP
file[k] = source[k]
else
//@ts-ignore oh my god you shut up too
v(files, source[k], uploadId, file)
}).filter(e => Boolean(e))
if (!noWrite) {
Accounts.Db.save()
files.db.save()
}
}

View file

@ -1,106 +1,123 @@
import crypto from "crypto"
import express from "express"
import { getCookie } from "hono/cookie"
import type { Context } from "hono"
import { readFile, writeFile } from "fs/promises"
export let AuthTokens: AuthToken[] = []
export let AuthTokenTO:{[key:string]:NodeJS.Timeout} = {}
import { z } from "zod"
import { AuthSchemas } from "./schemas/index.js"
import DbFile from "./dbfile.js"
import * as jose from "jose"
import { AccountResolvable, resolve as resolveAccount } from "./accounts.js"
import config from "./config.js"
export let AuthTokenTO: { [key: string]: NodeJS.Timeout } = {}
export const ValidTokenPermissions = [
"user", // permissions to /auth/me, with email docked
"email", // adds email back to /auth/me
"private", // allows app to read private files
"upload", // allows an app to upload under an account
"manage", // allows an app to manage an account's files
"customize", // allows an app to change customization settings
"admin" // only available for accounts with admin
// gives an app access to all admin tools
] as const
export type Scope = z.infer<typeof AuthSchemas.Scope>
export type TokenType = z.infer<typeof AuthSchemas.TokenType>
export type AuthToken = z.infer<typeof AuthSchemas.AuthToken>
export type TokenResolvable = string | AuthToken
export type TokenType = "User" | "App"
export type TokenPermission = typeof ValidTokenPermissions[number]
export const Db = new DbFile<AuthToken[]>("tokens", [])
export interface AuthToken {
account: string,
token: string,
expire: number,
type?: TokenType, // if !type, assume User
tokenPermissions?: TokenPermission[] // default to user if type is App,
// give full permissions if type is User
export function resolve(token: TokenResolvable, forCleanup?: boolean) {
let resolved = typeof token == "object" ? token : Db.data.find(e => e.id == token)
if (resolved && (forCleanup || resolved.expire == null || Date.now() < resolved.expire))
return resolved
}
export function create(
id:string,
expire:number=(24*60*60*1000),
type:TokenType="User",
tokenPermissions?:TokenPermission[]
account: AccountResolvable,
expire: number | null = 24 * 60 * 60 * 1000,
type: TokenType = "User",
scopes?: Scope[]
) {
let token = {
account:id,
token:crypto.randomBytes(36).toString('hex'),
expire: expire ? Date.now()+expire : 0,
let token = AuthSchemas.AuthToken.parse({
account: resolveAccount(account)?.id,
id: crypto.randomUUID(),
expire: typeof expire == "number" ? Date.now() + expire : null,
type,
tokenPermissions: type == "App" ? tokenPermissions || ["user"] : undefined
}
AuthTokens.push(token)
scopes:
type != "User" ? scopes || ["user"] : undefined
})
Db.data.push(token)
tokenTimer(token)
save()
Db.save()
return token.token
return token
}
export function tokenFor(req: express.Request) {
return req.cookies.auth || (
req.header("authorization")?.startsWith("Bearer ")
? req.header("authorization")?.split(" ")[1]
: undefined
)
export async function getJwtId(jwt: string) {
let result = await jose.jwtVerify(jwt, config.jwtSecret).catch(e => null)
return result ? result.payload.jti : undefined
}
function getToken(token:string) {
return AuthTokens.find(e => e.token == token && (e.expire == 0 || Date.now() < e.expire))
export function makeJwt(_token: TokenResolvable) {
let token = resolve(_token)!
let jwt = new jose.SignJWT({
exp: token.expire ? token.expire/1000 : undefined,
sub: token.account,
jti: token.id,
...(token.type != "User" ? { scope: token.scopes } : {})
}).setProtectedHeader({ alg: "HS256" })
return jwt.sign(config.jwtSecret)
}
export function validate(token:string) {
return getToken(token)?.account
export async function tokenFor(ctx: Context) {
let token =
getCookie(ctx, "auth")
|| (ctx.req.header("authorization")?.startsWith("Bearer ")
? ctx.req.header("authorization")?.split(" ")[1]
: undefined)
if (!token) return
let jti = await getJwtId(token)
return jti
}
export function getType(token:string): TokenType | undefined {
return getToken(token)?.type
export function validate(token: TokenResolvable) {
return resolve(token)?.account
}
export function getPermissions(token:string): TokenPermission[] | undefined {
return getToken(token)?.tokenPermissions
export function getType(token: TokenResolvable) {
return resolve(token)?.type
}
export function tokenTimer(token:AuthToken) {
if (!token.expire) return // justincase
export function getScopes(token: TokenResolvable): Scope[] | undefined {
let tok = resolve(token)
if (tok && "scopes" in tok)
return tok.scopes
}
export function tokenTimer(token: AuthToken) {
if (!token.expire) return
if (Date.now() >= token.expire) {
invalidate(token.token)
invalidate(token)
return
}
AuthTokenTO[token.token] = setTimeout(() => invalidate(token.token),token.expire-Date.now())
AuthTokenTO[token.id] = setTimeout(
() => invalidate(token),
token.expire - Date.now()
)
}
export function invalidate(token:string) {
if (AuthTokenTO[token]) {
clearTimeout(AuthTokenTO[token])
export function invalidate(_token: TokenResolvable) {
let token = resolve(_token, true)!
if (AuthTokenTO[token.id]) {
clearTimeout(AuthTokenTO[token.id])
}
AuthTokens.splice(AuthTokens.findIndex(e => e.token == token),1)
save()
Db.data.splice(
Db.data.indexOf(token),
1
)
Db.save()
}
export function save() {
writeFile(`${process.cwd()}/.data/tokens.json`,JSON.stringify(AuthTokens))
.catch((err) => console.error(err))
}
readFile(`${process.cwd()}/.data/tokens.json`)
.then((buf) => {
AuthTokens = JSON.parse(buf.toString())
AuthTokens.forEach(e => tokenTimer(e))
}).catch(err => console.error(err))
Db.read()
.then(() => {
Db.data.forEach((e) => tokenTimer(e))
})

88
src/server/lib/codes.ts Normal file
View file

@ -0,0 +1,88 @@
import { generateFileId } from "./files.js";
import crypto from "node:crypto"
export type Intent = "verifyEmail" | "recoverAccount" | "identityProof"
export const Intents = {
verifyEmail: {
limit: 2
},
recoverAccount: {},
identityProof: {
codeGenerator: crypto.randomUUID
}
} as Record<Intent, {codeGenerator?: () => string, limit?: number}>
export function isIntent(intent: string): intent is Intent {
return intent in Intents
}
export let codes = Object.fromEntries(
Object.keys(Intents).map((e) => [
e,
{
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
export class Code {
readonly id: string
readonly for: string
readonly intent: Intent
readonly expiryClear: NodeJS.Timeout
readonly data: any
constructor(
intent: Intent,
forUser: string,
data?: any,
time: number = 15 * 60 * 1000
) {
const { codeGenerator = () => generateFileId(12) } = Intents[intent]
this.for = forUser
this.intent = intent
this.expiryClear = setTimeout(this.terminate.bind(this), time)
this.data = data
this.id = codeGenerator()
let byUser = codes[intent].byUser.get(forUser)
if (!byUser) {
byUser = []
codes[intent].byUser.set(forUser, byUser)
}
codes[intent].byId.set(this.id, this)
byUser.push(this)
}
terminate() {
codes[this.intent].byId.delete(this.id)
let bu = codes[this.intent].byUser.get(this.for)!
bu.splice(bu.indexOf(this), 1)
clearTimeout(this.expiryClear)
}
check(forUser: string) {
return forUser === this.for
}
}
export function code(...params: ConstructorParameters<typeof Code>): { success: true, code: Code } | { success: false, error: string } {
const [intent, forUser] = params
const {limit = 100} = Intents[intent]
const {length: codeCount} = codes[intent].byUser.get(forUser) || [];
if (codeCount >= limit)
return { success: false, error: `Too many active codes for intent ${intent} (${limit})` }
else
return { success: true, code: new Code(...params) }
}

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

@ -0,0 +1,79 @@
import "dotenv/config"
export interface Configuration {
port: number
requestTimeout: number
trustProxy: boolean
forceSSL: boolean
discordToken: string
maxDiscordFiles: number
maxDiscordFileSize: number
maxUploadIdLength: number
targetChannel: string
accounts: {
registrationEnabled: boolean
requiredForUpload: boolean
}
mail: {
enabled: boolean
transport: {
host: string
port: number
secure: boolean
}
send: {
from: string
}
user: string
pass: string
},
jwtSecret: Buffer
}
export interface ClientConfiguration {
version: string
files: number
totalSize: number
mailEnabled: boolean
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),
targetChannel: process.env.TARGET__CHANNEL,
accounts: {
registrationEnabled:
process.env.ACCOUNTS__REGISTRATION_ENABLED === "true",
requiredForUpload: process.env.ACCOUNTS__REQUIRED_FOR_UPLOAD === "true",
},
mail: {
enabled: ["HOST","PORT","SEND_FROM","USER","PASS"].every(e => Boolean(process.env[`MAIL__${e}`])),
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,
},
jwtSecret: Buffer.from(process.env.JWT_SECRET!)
} as Configuration

177
src/server/lib/dbfile.ts Normal file
View file

@ -0,0 +1,177 @@
import { readFile, writeFile, readdir, mkdir } from "fs/promises"
import { existsSync } from "fs"
import path from "node:path"
const DATADIR = `./.data`
const TICK = 500
export let dbs: Record<string, DbFile<any>> = {}
export type Write = ReturnType<typeof writeFile>
// this is fucking stupid why did i write this
class Activity {
_write: () => Promise<any>
destroy: () => void
goal: number = Date.now()
lastWrite: number = Date.now()
clock? : { type: "precise", id: NodeJS.Timeout } | { type: "tick", id: NodeJS.Timeout, lastGoal: number }
constructor(writeFunc: () => Promise<any>, destroyFunc: () => void) {
this._write = writeFunc
this.destroy = destroyFunc
}
write() {
this.lastWrite = Date.now();
return this._write()
}
finish() {
this.stopClock()
this.write()
this.destroy()
}
tick() {
if (!this.clock || !("lastGoal" in this.clock)) return
if (Date.now() > this.goal) return this.finish();
if (this.goal == this.clock.lastGoal)
this.startPreciseClock()
else
this.clock.lastGoal = this.goal
if (Date.now()-this.lastWrite > 15000)
this.write()
}
stopClock() {
if (this.clock) clearTimeout(this.clock.id)
}
startTickClock() {
this.stopClock()
this.clock = {
type: "tick",
id: setInterval(this.tick.bind(this), TICK),
lastGoal: this.goal
}
}
startPreciseClock() {
this.stopClock()
this.clock = {
type: "precise",
id: setTimeout(this.finish.bind(this), this.goal-Date.now())
}
}
set() {
this.goal = Date.now()+5000
if (!this.clock || this.clock.type != "tick")
this.startTickClock()
}
}
export default class DbFile<Structure extends ({}|[])> {
name: string
data: Structure
activity?: Activity
private writeInProgress?: Promise<void>
private rewriteNeeded: boolean = false
private readonly files: string[]
readInProgress?: Promise<void>
constructor(name: string, defaultData: Structure) {
this.name = name
this.data = defaultData
this.files = [`${name}.json`, `${name}-b.json`].map(e => path.join(DATADIR, e))
dbs[this.name] = this
}
private async findAvailable() {
// would it be worth it to remove existsSync here?
// mkdir seems to already do it itself when recursive is true
if (!existsSync(DATADIR))
await mkdir(DATADIR, { recursive: true })
return (await readdir(DATADIR))
.filter(e => e.match(new RegExp(`^${this.name}(?:-b)?.json$`)))
.map(e => path.join(DATADIR, e))
}
/**
* @description Write files to disk; doesn't care about preventing corruption aside from the 2 copies
*/
private async write() {
let data = JSON.stringify(this.data)
for (let x of this.files)
await writeFile(x, data)
}
/**
* @description Write files to disk; checks if a write is in progress first
*/
private async queueWrite(): Promise<void> {
if (this.writeInProgress) { // if write in progress
this.rewriteNeeded = true // signify that a rewrite is needed
return this.writeInProgress
}
this.writeInProgress = this.write()
await this.writeInProgress; // wait for it to complete
delete this.writeInProgress; // then remove it
if (this.rewriteNeeded) return this.queueWrite() // queues up another write if needed
}
/**
* @description Starts saving data to disk
*/
async save() {
if (!this.activity)
this.activity =
new Activity(
this.queueWrite.bind(this),
() => delete this.activity
)
this.activity.set()
}
private async tryRead(path: string) {
return JSON.parse((await readFile(path)).toString())
}
private async _read() {
let availFiles = await this.findAvailable()
if (availFiles.length == 0) return
for (let x of availFiles) {
let data = await this.tryRead(x).catch(_ => null)
if (data !== null) {
this.data = data
return
}
}
throw new Error(`Failed to read any of the available files for DbFile ${this.name}`)
}
read() {
this.readInProgress = this._read()
return this.readInProgress
}
}

View file

@ -1,48 +1,36 @@
import { Response } from "express";
import { readFile } from "fs/promises"
import type { Context } from "hono"
import type { StatusCode } from "hono/utils/http-status"
let errorPage:string
let errorPage: string
/**
* @description Serves an error as a response to a request with an error page attached
* @param res Express response object
* @param ctx Express response object
* @param code Error code
* @param reason Error reason
*/
export default async function ServeError(
res:Response,
code:number,
reason:string
ctx: Context,
code: number,
reason: string
) {
// fetch error page if not cached
if (!errorPage) {
errorPage =
(
await readFile(`${process.cwd()}/pages/error.html`)
.catch((err) => console.error(err))
|| "<pre>$code $text</pre>"
)
.toString()
}
errorPage ??= (
(await readFile(`${process.cwd()}/dist/error.html`).catch((err) =>
console.error(err)
)) ?? "<pre>$code $text</pre>"
).toString()
// serve error
res.statusMessage = reason
res.status(code)
res.header("x-backup-status-message", reason) // glitch default nginx configuration
res.send(
return ctx.req.header("accept")?.includes("text/html") ? ctx.html(
errorPage
.replace(/\$code/g,code.toString())
.replace(/\$text/g,reason)
)
.replaceAll("$code", code.toString())
.replaceAll("$text", reason),
code as StatusCode/*,
{
"x-backup-status-message": reason, // glitch default nginx configuration
}*/
) : ctx.text(reason, code as StatusCode)
}
/**
* @description Redirects a user to another page.
* @param res Express response object
* @param url Target URL
* @deprecated Use `res.redirect` instead.
*/
export function Redirect(res:Response,url:string) {
res.status(302)
res.header("Location",url)
res.send()
}

File diff suppressed because it is too large Load diff

35
src/server/lib/invites.ts Normal file
View file

@ -0,0 +1,35 @@
// The only reason we have this is to make
// life very, very slightly easier.
// And also we can change how the invite
// system works a little easily
// if need be, I guess?
import DbFile from "./dbfile.js";
import { generateFileId } from "./files.js";
export const Db = new DbFile<string[]>("invites", [])
export function has(id: string) {
return Db.data.includes(id)
}
export function use(id: string) {
if (!has(id)) return false
Db.data.splice(
Db.data.indexOf(id),
1
)
Db.save()
return true
}
export function make() {
let invite = generateFileId(6)
Db.data.push(invite)
Db.save()
return invite
}
Db.read()

View file

@ -1,23 +1,17 @@
import { createTransport } from "nodemailer";
import { createTransport } from "nodemailer"
import config from "./config.js"
// required i guess
require("dotenv").config()
let
mailConfig =
require( process.cwd() + "/config.json" ).mail,
transport =
createTransport(
{
...mailConfig.transport,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS
}
}
)
// lazy but
const { mail } = config
const transport = createTransport({
host: mail.transport.host,
port: mail.transport.port,
secure: mail.transport.secure,
from: mail.send.from,
auth: {
user: mail.user,
pass: mail.pass,
},
})
/**
* @description Sends an email
@ -26,20 +20,20 @@ transport =
* @param content Email content
* @returns Promise which resolves to the output from nodemailer.transport.sendMail
*/
export function sendMail(to: string, subject: string, content: string) {
return new Promise((resolve,reject) => {
transport.sendMail({
to,
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
.replace(/\<span username\>/g, `<span code><span style="color:#DDAA66;padding-right:3px;">@</span>`)
.replace(/\<span code\>/g,`<span style="font-family:monospace;padding:3px 5px 3px 5px;border-radius:8px;background-color:#1C1C1C;color:#DDDDDD;">`)
}<br><br><span style="opacity:0.5">If you do not believe that you are the intended recipient of this email, please disregard this message.</span>`
}, (err, info) => {
if (err) reject(err)
else resolve(info)
})
export async function sendMail(to: string, subject: string, content: string) {
if (!config.mail.enabled) return false
return transport.sendMail({
to,
subject,
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(
"<span username>",
`<span code><span style="color:#DDAA66;padding-right:3px;">@</span>`
)
.replaceAll(
"<span code>",
`<span style="font-family:monospace;padding:3px 5px 3px 5px;border-radius:8px;background-color:#1C1C1C;color:#DDDDDD;">`
)}<br><br><span style="opacity:0.5">If you do not believe that you are the intended recipient of this email, please disregard this message.</span>`,
})
}

View file

@ -1,36 +1,109 @@
import * as Accounts from "./accounts";
import express, { type RequestHandler } from "express"
import ServeError from "../lib/errors";
import * as auth from "./auth";
import * as Accounts from "./accounts.js"
import type { Context, Hono, Handler as RequestHandler } from "hono"
import ServeError from "../lib/errors.js"
import * as auth from "./auth.js"
import { setCookie } from "hono/cookie"
import { z } from "zod"
import { codes } from "./codes.js"
/**
* @description Middleware which adds an account, if any, to res.locals.acc
* @description Middleware which adds an account, if any, to ctx.get("account")
*/
export const getAccount: RequestHandler = function(req, res, next) {
res.locals.acc = Accounts.getFromToken(auth.tokenFor(req))
next()
export const getAccount: RequestHandler = async function (ctx, next) {
let uToken = (await auth.tokenFor(ctx))!
let account = Accounts.getFromToken(uToken)
if (account?.suspension)
auth.invalidate(uToken)
ctx.set("account", account)
return next()
}
export function resolveTarget(actor: Accounts.Account, target: Accounts.AccountResolvable) {
return target == "me"
? actor
: Accounts.resolve(target)
}
/**
* @description Middleware which blocks requests which do not have res.locals.acc set
* @description use :user param to get a target for this route
*/
export const requiresAccount: RequestHandler = function(_req, res, next) {
if (!res.locals.acc) {
ServeError(res, 401, "not logged in")
return
}
next()
export const getTarget: RequestHandler = async (ctx, next) => {
let tok = await auth.tokenFor(ctx)
let permissions
if (tok && auth.getType(tok) != "User")
permissions = auth.getScopes(tok)
let actor = ctx.get("account")
let target = resolveTarget(actor, ctx.req.param("user"))
if (!target) return ServeError(ctx, 404, "account does not exist")
if (actor && (
(
target != actor // target is not the current account
&& (
!actor?.admin // account is not admin
|| (
permissions && !permissions.includes("manage_server") // account is admin but permissions does not include manage_server
)
)
)
))
return ServeError(ctx, 403, "you cannot manage this user")
ctx.set("target", target)
return next()
}
/**
* @description Middleware which blocks requests that have res.locals.acc.admin set to a falsy value
* @description Blocks routes with a target user set to the account performing the action from bot tokens which do not have the manage_account permission
*/
export const requiresAdmin: RequestHandler = function(_req, res, next) {
if (!res.locals.acc.admin) {
ServeError(res, 403, "you are not an administrator")
return
export const accountMgmtRoute: RequestHandler = async (ctx,next) => {
let tok = await auth.tokenFor(ctx)
let permissions
if (tok && auth.getType(tok) != "User")
permissions = auth.getScopes(tok)
if (
(
ctx.get("account") == ctx.get("target") // if the current target is the user account
&& (permissions && !permissions.includes("manage_account")) // if permissions does not include manage_account
)
)
return ServeError(ctx, 403, "you cannot manage this user")
return next()
}
/**
* @description Middleware which blocks requests which do not have ctx.get("account") set
*/
export const requiresAccount: RequestHandler = function (ctx, next) {
if (!ctx.get("account"))
return ServeError(ctx, 401, "not logged in")
return next()
}
/**
* @description Middleware which blocks requests which do not have ctx.get("target") set
*/
export const requiresTarget: RequestHandler = function (ctx, next) {
if (!ctx.get("target")) {
return ServeError(ctx, 404, "no target account")
}
next()
return next()
}
/**
* @description Middleware which blocks requests that have ctx.get("account").admin set to a falsy value
*/
export const requiresAdmin: RequestHandler = function (ctx, next) {
if (!ctx.get("account").admin) {
return ServeError(ctx, 403, "you are not an administrator")
}
return next()
}
/**
@ -38,36 +111,115 @@ export const requiresAdmin: RequestHandler = function(_req, res, next) {
* @param tokenPermissions Permissions which your route requires.
* @returns Express middleware
*/
export const requiresPermissions = function(...tokenPermissions: auth.TokenPermission[]): RequestHandler {
return function(req, res, next) {
let token = auth.tokenFor(req)
export const requiresScopes = function (
...wantsScopes: auth.Scope[]
): RequestHandler {
return async function (ctx, next) {
let token = (await auth.tokenFor(ctx))!
let type = auth.getType(token)
if (type == "App") {
let permissions = auth.getPermissions(token)
if (!permissions) ServeError(res, 403, "insufficient permissions")
else {
for (let v of tokenPermissions) {
if (!permissions.includes(v as auth.TokenPermission)) {
ServeError(res,403,"insufficient permissions")
return
if (type != "User") {
let scopes = auth.getScopes(token)
if (!scopes) return ServeError(ctx, 403, "insufficient permissions")
else {
for (let v of wantsScopes) {
if (!scopes.includes(v)) {
return ServeError(ctx, 403, "insufficient permissions")
}
}
next()
}
} else next()
}
return next()
}
}
/**
* @description Blocks requests based on whether or not the token being used to access the route is of type `User`.
* @description Blocks requests based on whether or not the token being used to access the route is of type `User`.
*/
export const noAPIAccess: RequestHandler = function(req, res, next) {
if (auth.getType(auth.tokenFor(req)) == "App") ServeError(res, 403, "apps are not allowed to access this endpoint")
else next()
}
export const noAPIAccess: RequestHandler = async function (ctx, next) {
if (auth.getType((await auth.tokenFor(ctx))!) == "App")
return ServeError(ctx, 403, "apps are not allowed to access this endpoint")
else return next()
}
/**
@description Add a restriction to this route; the condition must be true to allow API requests.
*/
export const assertAPI = function (
condition: (ctx: Context) => boolean
): RequestHandler {
return async function (ctx, next) {
let reqToken = (await auth.tokenFor(ctx))!
if (
auth.getType(reqToken) != "User" &&
condition(ctx)
)
return ServeError(
ctx,
403,
"apps are not allowed to access this endpoint"
)
else return next()
}
}
export const issuesToMessage = function(issues: z.ZodIssue[]) {
return issues.map(e => `${e.path}: ${e.code} :: ${e.message}`).join("; ")
}
export function scheme(scheme: z.ZodTypeAny, transformer: (ctx: Context) => Promise<any>|any = c => c.req.json()): RequestHandler {
return async function(ctx, next) {
let data = transformer(ctx)
let chk = await scheme.safeParse(data instanceof Promise ? await data : data)
ctx.set("parsedScheme", chk.data)
if (chk.success)
return next()
else
return ServeError(ctx, 400, issuesToMessage(chk.error.issues))
}
}
// this is bad but idgaf
export function runtimeEvaluatedScheme(sch: (c: Context) => z.ZodTypeAny, transformer?: Parameters<typeof scheme>[1]): RequestHandler {
return async function(ctx, next) {
return scheme(sch(ctx),transformer)(ctx, next)
}
}
// Not really middleware but a utility
export const login = async (ctx: Context, account: Accounts.AccountResolvable) => {
let token = auth.create(account, 3 * 24 * 60 * 60 * 1000)
setCookie(ctx, "auth", await auth.makeJwt(token), {
path: "/",
sameSite: "Strict",
secure: true,
httpOnly: true
})
return token
}
export const verifyPoi = (user: string, poi?: string, wantsMfaPoi: boolean = false) => {
if (!poi) return false
let poiCode = codes.identityProof.byId.get(poi)
if (!poiCode || poiCode.for !== user || poiCode.data == wantsMfaPoi)
return false
poiCode.terminate()
return true
}
export const mirror = (apiRoot: Hono, ctx: Context, url: string, init: Partial<RequestInit>) => apiRoot.fetch(
new Request(
(new URL(url, ctx.req.raw.url)).href,
init.body ? {...ctx.req.raw, headers: ctx.req.raw.headers, ...init} : Object.assign(ctx.req.raw, init)
),
ctx.env
)

View file

@ -0,0 +1,6 @@
// bad but works for now
import {readFile} from "fs/promises"
export default JSON.parse(
(await readFile("./package.json"))
.toString()
) satisfies { version: string }

View file

@ -1,50 +1,50 @@
import { RequestHandler } from "express"
import { type Account } from "./accounts"
import ServeError from "./errors"
import type { Handler } from "hono"
import ServeError from "./errors.js"
interface RatelimitSettings {
requests: number
per: number
}
/**
* @description Ratelimits a route based on res.locals.acc
* @description Ratelimits a route based on ctx.get("account")
* @param settings Ratelimit settings
* @returns Express middleware
*/
export function accountRatelimit( settings: RatelimitSettings ): RequestHandler {
export function accountRatelimit(settings: RatelimitSettings): Handler {
let activeLimits: {
[ key: string ]: {
requests: number,
[key: string]: {
requests: number
expirationHold: NodeJS.Timeout
}
} = {}
return (req, res, next) => {
if (res.locals.acc) {
let accId = res.locals.acc.id
return (ctx, next) => {
if (ctx.get("account")) {
let accId = ctx.get("account").id
let aL = activeLimits[accId]
if (!aL) {
activeLimits[accId] = {
requests: 0,
expirationHold: setTimeout(() => delete activeLimits[accId], settings.per)
expirationHold: setTimeout(
() => delete activeLimits[accId],
settings.per
),
}
aL = activeLimits[accId]
}
if (aL.requests < settings.requests) {
res.locals.undoCount = () => {
ctx.set("undoCount", () => {
if (activeLimits[accId]) {
activeLimits[accId].requests--
}
}
next()
})
return next()
} else {
ServeError(res, 429, "too many requests")
return ServeError(ctx, 429, "too many requests")
}
}
}
}
}

View file

@ -0,0 +1,74 @@
import {z} from "zod"
import { FileId, FileVisibility } from "./files.js"
import { RGBHex } from "./misc.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: RGBHex.optional(),
largeImage: z.boolean().default(false)
})
export const User = z.object({
interface: Interface.default({}), links: Links.default({})
})
}
export const Suspension =
z.object({
reason: z.string(),
until: z.number().nullable()
})
export const Account =
z.object({
id: z.string(),
username: Username,
email: z.optional(z.string().email("must be an email")),
password: Password,
files: z.array(z.string()),
admin: z.boolean(),
defaultFileVisibility: FileVisibility,
settings: Settings.User,
suspension: Suspension.optional()
})

View file

@ -0,0 +1,40 @@
import { z } from "zod";
export const Scope = z.enum([
"user", // permissions to /auth/me, with email docked
"email", // adds email back to /auth/me
"private", // allows app to manage and read private files
"manage_files", // allows an app to manage an account's files
"manage_account", // allows an app to manage an account
"manage_server" // allows an app to affect other users, files on admin accounts
])
export const TokenType = z.enum([
"User",
"ApiKey",
"App"
])
const BaseAuthToken = z.object({
account: z.string(),
id: z.string(),
expire: z.number()
.nullable()
.refine(e => e == null || e > Date.now(), "expiration must be after now"),
type: TokenType
})
export const AuthToken = z.discriminatedUnion("type",[
BaseAuthToken.extend({
type: z.literal("User")
}),
BaseAuthToken.extend({
type: z.literal("ApiKey"),
scopes: z.array(Scope).default(["user"])
}),
BaseAuthToken.extend({
type: z.literal("App"),
scopes: z.array(Scope).default(["user"])
})
])

View file

@ -0,0 +1,21 @@
import {z} from "zod"
import config from "../config.js"
export const FileId = z.string()
.regex(/^[A-Za-z0-9_\-\.\!\=\:\&\$\,\+\;\@\~\*\(\)\']+$/,"file ID uses invalid characters")
.max(config.maxUploadIdLength,"file ID too long")
.min(1, "you... *need* a file ID")
export const FileVisibility = z.enum(["public", "anonymous", "private"])
export const FileTag = z.string().toLowerCase().regex(/^[a-z\-]+$/, "invalid characters").max(30, "tag length too long")
export const FilePointer = z.object({
filename: z.string().max(512, "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.array().max(5)),
visibility: z.optional(FileVisibility).default("public"),
chunkSize: z.optional(z.number()),
lastModified: z.optional(z.number()),
md5: z.optional(z.string())
})

View file

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

View file

@ -0,0 +1,3 @@
import { z } from "zod";
export const RGBHex = z.string().toLowerCase().length(6).regex(/^[a-f0-9]+$/,"illegal characters")

View file

@ -1,235 +0,0 @@
import bodyParser from "body-parser";
import { Router } from "express";
import * as Accounts from "../lib/accounts";
import * as auth from "../lib/auth";
import bytes from "bytes"
import {writeFile} from "fs";
import { sendMail } from "../lib/mail";
import { getAccount, requiresAccount, requiresAdmin, requiresPermissions } from "../lib/middleware"
import ServeError from "../lib/errors";
import Files from "../lib/files";
let parser = bodyParser.json({
type: ["text/plain","application/json"]
})
export let adminRoutes = Router();
adminRoutes
.use(getAccount)
.use(requiresAccount)
.use(requiresAdmin)
.use(requiresPermissions("admin"))
let files:Files
export function setFilesObj(newFiles:Files) {
files = newFiles
}
let config = require(`${process.cwd()}/config.json`)
adminRoutes.post("/reset", parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.target !== "string" || typeof req.body.password !== "string") {
res.status(404)
res.send()
return
}
let targetAccount = Accounts.getFromUsername(req.body.target)
if (!targetAccount) {
res.status(404)
res.send()
return
}
Accounts.password.set ( targetAccount.id, req.body.password )
auth.AuthTokens.filter(e => e.account == targetAccount?.id).forEach((v) => {
auth.invalidate(v.token)
})
if (targetAccount.email) {
sendMail(targetAccount.email, `Your login details have been updated`, `<b>Hello there!</b> This email is to notify you of a password change that an administrator, <span username>${acc.username}</span>, has initiated. You have been logged out of your devices. Thank you for using monofile.`).then(() => {
res.send("OK")
}).catch((err) => {})
}
res.send()
})
adminRoutes.post("/elevate", parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.target !== "string") {
res.status(404)
res.send()
return
}
let targetAccount = Accounts.getFromUsername(req.body.target)
if (!targetAccount) {
res.status(404)
res.send()
return
}
targetAccount.admin = true;
Accounts.save()
res.send()
})
adminRoutes.post("/delete", parser, (req,res) => {
if (typeof req.body.target !== "string") {
res.status(404)
res.send()
return
}
let targetFile = files.getFilePointer(req.body.target)
if (!targetFile) {
res.status(404)
res.send()
return
}
files.unlink(req.body.target).then(() => {
res.status(200)
}).catch(() => {
res.status(500)
}).finally(() => res.send())
})
adminRoutes.post("/delete_account", parser, async (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.target !== "string") {
res.status(404)
res.send()
return
}
let targetAccount = Accounts.getFromUsername(req.body.target)
if (!targetAccount) {
res.status(404)
res.send()
return
}
let accId = targetAccount.id
auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
auth.invalidate(v.token)
})
let cpl = () => Accounts.deleteAccount(accId).then(_ => {
if (targetAccount?.email) {
sendMail(targetAccount.email, "Notice of account deletion", `Your account, <span username>${targetAccount.username}</span>, has been deleted by <span username>${acc.username}</span> for the following reason: <br><br><span style="font-weight:600">${req.body.reason || "(no reason specified)"}</span><br><br> Your files ${req.body.deleteFiles ? "have been deleted" : "have not been modified"}. Thank you for using monofile.`)
}
res.send("account deleted")
})
if (req.body.deleteFiles) {
let f = targetAccount.files.map(e=>e) // make shallow copy so that iterating over it doesnt Die
for (let v of f) {
files.unlink(v,true).catch(err => console.error(err))
}
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(files.files), (err) => {
if (err) console.log(err)
cpl()
})
} else cpl()
})
adminRoutes.post("/transfer", parser, (req,res) => {
if (typeof req.body.target !== "string" || typeof req.body.owner !== "string") {
res.status(404)
res.send()
return
}
let targetFile = files.getFilePointer(req.body.target)
if (!targetFile) {
res.status(404)
res.send()
return
}
let newOwner = Accounts.getFromUsername(req.body.owner || "")
// clear old owner
if (targetFile.owner) {
let oldOwner = Accounts.getFromId(targetFile.owner)
if (oldOwner) {
Accounts.files.deindex(oldOwner.id, req.body.target)
}
}
if (newOwner) {
Accounts.files.index(newOwner.id, req.body.target)
}
targetFile.owner = newOwner ? newOwner.id : undefined;
files.writeFile(req.body.target, targetFile).then(() => {
res.send()
}).catch(() => {
res.status(500)
res.send()
}) // wasting a reassignment but whatee
})
adminRoutes.post("/idchange", parser, (req,res) => {
if (typeof req.body.target !== "string" || typeof req.body.new !== "string") {
res.status(400)
res.send()
return
}
let targetFile = files.getFilePointer(req.body.target)
if (!targetFile) {
res.status(404)
res.send()
return
}
if (files.getFilePointer(req.body.new)) {
res.status(400)
res.send()
return
}
if (targetFile.owner) {
Accounts.files.deindex(targetFile.owner, req.body.target)
Accounts.files.index(targetFile.owner, req.body.new)
}
delete files.files[req.body.target]
files.writeFile(req.body.new, targetFile).then(() => {
res.send()
}).catch(() => {
files.files[req.body.target] = req.body.new
if (targetFile.owner) {
Accounts.files.deindex(targetFile.owner, req.body.new)
Accounts.files.index(targetFile.owner, req.body.target)
}
res.status(500)
res.send()
})
})

82
src/server/routes/api.ts Normal file
View file

@ -0,0 +1,82 @@
import { Hono } from "hono"
import Files from "../lib/files.js"
import { fileURLToPath } from "url"
import { dirname } from "path"
import { readdir } from "fs/promises"
const APIDirectory = dirname(fileURLToPath(import.meta.url)) + "/api"
interface APIMount {
file: string
to: string
}
type APIMountResolvable = string | APIMount
export interface APIDefinition {
name: string
baseURL: string
mount: APIMountResolvable[]
}
function resolveMount(mount: APIMountResolvable): APIMount {
return typeof mount == "string" ? { file: mount, to: "/" + mount } : mount
}
class APIVersion {
readonly definition: APIDefinition
readonly apiPath: string
readonly apiRoot: Hono
readonly root: Hono = new Hono()
readonly files: Files
constructor(definition: APIDefinition, files: Files, apiRoot: Hono) {
this.definition = definition
this.apiPath = APIDirectory + "/" + definition.name
this.files = files
this.apiRoot = apiRoot
}
async load() {
for (let _mount of this.definition.mount) {
let mount = resolveMount(_mount)
// 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 }
this.root.route(mount.to, route(this.files, this.apiRoot))
}
}
}
export default class APIRouter {
readonly files: Files
readonly root: Hono = new Hono()
constructor(files: Files) {
this.files = files
}
/**
* @description Mounts an APIDefinition to the APIRouter.
* @param definition Definition to mount.
*/
private async mount(definition: APIDefinition) {
console.log(`mounting APIDefinition ${definition.name}`)
let def = new APIVersion(definition, this.files, this.root)
await def.load()
this.root.route(definition.baseURL, def.root)
}
async loadAPIMethods() {
let files = await readdir(APIDirectory)
for (let version of files) {
let def = (await import(`${APIDirectory}/${version}/definition.js`)).default
await this.mount(def)
}
}
}

View file

@ -0,0 +1,219 @@
import { Hono } from "hono"
import * as Accounts from "../../../lib/accounts.js"
import * as auth from "../../../lib/auth.js"
import { writeFile } from "fs/promises"
import { sendMail } from "../../../lib/mail.js"
import {
getAccount,
requiresAccount,
requiresAdmin,
requiresScopes,
} from "../../../lib/middleware.js"
import Files from "../../../lib/files.js"
export let adminRoutes = new Hono<{
Variables: {
account: Accounts.Account
}
}>()
adminRoutes
.use(getAccount)
.use(requiresAccount)
.use(requiresAdmin)
.use(requiresScopes("manage_server"))
export default function (files: Files) {
adminRoutes.post("/reset", async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
const body = await ctx.req.json()
if (
typeof body.target !== "string" ||
typeof body.password !== "string"
) {
return ctx.text("not found", 404)
}
let targetAccount = Accounts.getFromUsername(body.target)
if (!targetAccount) {
return ctx.text("not found", 404)
}
Accounts.password.set(targetAccount.id, body.password)
auth.Db.data.filter((e) => e.account == targetAccount?.id).forEach(
(v) => {
auth.invalidate(v.id)
}
)
if (targetAccount.email) {
return sendMail(
targetAccount.email,
`Your login details have been updated`,
`<b>Hello there!</b> This email is to notify you of a password change that an administrator, <span username>${acc.username}</span>, has initiated. You have been logged out of your devices. Thank you for using monofile.`
)
.then(() => ctx.text("OK"))
.catch(() => ctx.text("err while sending email", 500))
}
})
adminRoutes.post("/elevate", async (ctx) => {
const body = await ctx.req.json()
let acc = ctx.get("account") as Accounts.Account
if (typeof body.target !== "string") {
return ctx.text("not found", 404)
}
let targetAccount = Accounts.getFromUsername(body.target)
if (!targetAccount) {
return ctx.text("not found", 404)
}
Accounts.Db.save()
return ctx.text("OK")
})
adminRoutes.post("/delete", async (ctx) => {
const body = await ctx.req.json()
if (typeof body.target !== "string") {
return ctx.text("not found", 404)
}
let targetFile = files.db.data[body.target]
if (!targetFile) {
return ctx.text("not found", 404)
}
return files
.unlink(body.target)
.then(() => ctx.text("ok", 200))
.catch(() => ctx.text("err", 500))
})
adminRoutes.post("/delete_account", async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
const body = await ctx.req.json()
if (typeof body.target !== "string") {
return ctx.text("not found", 404)
}
let targetAccount = Accounts.getFromUsername(body.target)
if (!targetAccount) {
return ctx.text("not found", 404)
}
let accId = targetAccount.id
auth.Db.data.filter((e) => e.account == accId).forEach((v) => {
auth.invalidate(v.id)
})
let cpl = () =>
Accounts.deleteAccount(accId).then((_) => {
if (targetAccount?.email) {
sendMail(
targetAccount.email,
"Notice of account deletion",
`Your account, <span username>${
targetAccount.username
}</span>, has been deleted by <span username>${
acc.username
}</span> for the following reason: <br><br><span style="font-weight:600">${
body.reason || "(no reason specified)"
}</span><br><br> Your files ${
body.deleteFiles
? "have been deleted"
: "have not been modified"
}. Thank you for using monofile.`
)
}
return ctx.text("account deleted")
})
if (body.deleteFiles) {
let f = targetAccount.files.map((e) => e) // make shallow copy so that iterating over it doesnt Die
for (let v of f) {
files.unlink(v, true).catch((err) => console.error(err))
}
return writeFile(
process.cwd() + "/.data/files.json",
JSON.stringify(files.db.data)
).then(cpl)
} else return cpl()
})
adminRoutes.post("/transfer", async (ctx) => {
const body = await ctx.req.json()
if (typeof body.target !== "string" || typeof body.owner !== "string") {
return ctx.text("not found", 404)
}
let targetFile = files.db.data[body.target]
if (!targetFile) {
return ctx.text("not found", 404)
}
let newOwner = Accounts.getFromUsername(body.owner || "")
// clear old owner
if (targetFile.owner) {
let oldOwner = Accounts.getFromId(targetFile.owner)
if (oldOwner) {
Accounts.files.deindex(oldOwner.id, body.target)
}
}
if (newOwner) {
Accounts.files.index(newOwner.id, body.target)
}
targetFile.owner = newOwner ? newOwner.id : undefined
return files.db
.save()
.then(() => ctx.text("ok", 200))
.catch(() => ctx.text("error", 500))
})
adminRoutes.post("/idchange", async (ctx) => {
const body = await ctx.req.json()
if (typeof body.target !== "string" || typeof body.new !== "string") {
return ctx.text("inappropriate body", 400)
}
let targetFile = files.db.data[body.target]
if (!targetFile) {
return ctx.text("not found", 404)
}
if (files.db.data[body.new]) {
return ctx.status(400)
}
if (targetFile.owner) {
Accounts.files.deindex(targetFile.owner, body.target)
Accounts.files.index(targetFile.owner, body.new)
}
delete files.db.data[body.target]
files.db.data[body.new] = targetFile
return files.db
.save()
.then(() => ctx.status(200))
.catch(() => {
files.db.data[body.target] = body.new
if (targetFile.owner) {
Accounts.files.deindex(targetFile.owner, body.new)
Accounts.files.index(targetFile.owner, body.target)
}
return ctx.status(500)
})
})
return adminRoutes
}

View file

@ -0,0 +1,549 @@
import { Hono, Handler } from "hono"
import { getCookie, setCookie } from "hono/cookie"
import * as Accounts from "../../../lib/accounts.js"
import * as auth from "../../../lib/auth.js"
import { sendMail } from "../../../lib/mail.js"
import {
getAccount,
login,
noAPIAccess,
requiresAccount,
requiresScopes,
} from "../../../lib/middleware.js"
import { accountRatelimit } from "../../../lib/ratelimit.js"
import config from "../../../lib/config.js"
import ServeError from "../../../lib/errors.js"
import Files, {
FileVisibility,
generateFileId
} from "../../../lib/files.js"
import { writeFile } from "fs/promises"
export let authRoutes = new Hono<{
Variables: {
account: Accounts.Account
}
}>()
authRoutes.all("*", getAccount)
export default function (files: Files) {
authRoutes.post("/login", async (ctx) => {
const body = await ctx.req.json()
if (
typeof body.username != "string" ||
typeof body.password != "string"
) {
return ServeError(ctx, 400, "please provide a username or password")
}
if (auth.validate(getCookie(ctx, "auth")!))
return ctx.text("You are already authed")
/*
check if account exists
*/
let acc = Accounts.getFromUsername(body.username)
if (!acc) {
return ServeError(ctx, 401, "username or password incorrect")
}
if (!Accounts.password.check(acc.id, body.password)) {
return ServeError(ctx, 401, "username or password incorrect")
}
/*
assign token
*/
login(ctx, acc.id)
return ctx.text("")
})
authRoutes.post("/create", async (ctx) => {
if (!config.accounts.registrationEnabled) {
return ServeError(ctx, 403, "account registration disabled")
}
if (auth.validate(getCookie(ctx, "auth")!)) return
const body = await ctx.req.json()
if (
typeof body.username != "string" ||
typeof body.password != "string"
) {
return ServeError(ctx, 400, "please provide a username or password")
}
/*
check if account exists
*/
let acc = Accounts.getFromUsername(body.username)
if (acc) {
return ServeError(ctx, 400, "account with this username already exists")
}
if (body.username.length < 3 || body.username.length > 20) {
return ServeError(
ctx,
400,
"username must be over or equal to 3 characters or under or equal to 20 characters in length"
)
}
if (
(body.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] != body.username
) {
return ServeError(ctx, 400, "username contains invalid characters")
}
if (body.password.length < 8) {
return ServeError(ctx, 400, "password must be 8 characters or longer")
}
return Accounts.create(body.username, body.password)
.then((newAcc) => {
/*
assign token
*/
login(ctx, newAcc)
return ctx.text("")
})
.catch(() => ServeError(ctx, 500, "internal server error"))
})
authRoutes.post("/logout", async (ctx) => {
if (!auth.validate(getCookie(ctx, "auth")!)) {
return ServeError(ctx, 401, "not logged in")
}
auth.invalidate(getCookie(ctx, "auth")!)
return ctx.text("logged out")
})
authRoutes.post(
"/dfv",
requiresAccount,
requiresScopes("manage_files"),
// Used body-parser
async (ctx) => {
const body = await ctx.req.json()
let acc = ctx.get("account") as Accounts.Account
if (
["public", "private", "anonymous"].includes(
body.defaultFileVisibility
)
) {
acc.defaultFileVisibility = body.defaultFileVisibility
Accounts.Db.save()
return ctx.text(
`dfv has been set to ${acc.defaultFileVisibility}`
)
} else {
return ctx.text("invalid dfv", 400)
}
}
)
authRoutes.post(
"/delete_account",
requiresAccount,
noAPIAccess,
// Used body-parser
async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
const body = await ctx.req.json()
let accId = acc.id
auth.Db.data.filter((e) => e.account == accId).forEach((v) => {
auth.invalidate(v.id)
})
let cpl = () =>
Accounts.deleteAccount(accId).then((_) =>
ctx.text("account deleted")
)
if (body.deleteFiles) {
let f = acc.files.map((e) => e) // make shallow copy so that iterating over it doesnt Die
for (let v of f) {
files.unlink(v, true).catch((err) => console.error(err))
}
return writeFile(
process.cwd() + "/.data/files.json",
JSON.stringify(files.db.data)
).then(cpl)
} else cpl()
}
)
authRoutes.post(
"/change_username",
requiresAccount,
noAPIAccess,
// Used body-parser
async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
const body = await ctx.req.json()
if (
typeof body.username != "string" ||
body.username.length < 3 ||
body.username.length > 20
) {
return ServeError(
ctx,
400,
"username must be between 3 and 20 characters in length"
)
}
let _acc = Accounts.getFromUsername(body.username)
if (_acc) {
return ServeError(
ctx,
400,
"account with this username already exists"
)
}
if (
(body.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] !=
body.username
) {
return ServeError(
ctx,
400,
"username contains invalid characters"
)
}
acc.username = body.username
Accounts.Db.save()
if (acc.email) {
return sendMail(
acc.email,
`Your login details have been updated`,
`<b>Hello there!</b> Your username has been updated to <span username>${body.username}</span>. Please update your devices accordingly. Thank you for using monofile.`
)
.then(() => ctx.text("OK"))
.catch((err) => {})
}
return ctx.text("username changed")
}
)
// shit way to do this but...
let verificationCodes = new Map<
string,
{ code: string; email: string; expiry: NodeJS.Timeout }
>()
authRoutes.post(
"/request_email_change",
requiresAccount,
noAPIAccess,
accountRatelimit({ requests: 4, per: 60 * 60 * 1000 }),
// Used body-parser
async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
const body = await ctx.req.json()
if (typeof body.email != "string" || !body.email) {
ServeError(ctx, 400, "supply an email")
return
}
let vcode = verificationCodes.get(acc.id)
// delete previous if any
let e = vcode?.expiry
if (e) clearTimeout(e)
verificationCodes.delete(acc?.id || "")
let code = generateFileId(12).toUpperCase()
// set
verificationCodes.set(acc.id, {
code,
email: body.email,
expiry: setTimeout(
() => verificationCodes.delete(acc?.id || ""),
15 * 60 * 1000
),
})
// this is a mess but it's fine
sendMail(
body.email,
`Hey there, ${acc.username} - let's connect your email`,
`<b>Hello there!</b> You are recieving this message because you decided to link your email, <span code>${
body.email.split("@")[0]
}<span style="opacity:0.5">@${
body.email.split("@")[1]
}</span></span>, to your account, <span username>${
acc.username
}</span>. If you would like to continue, please <a href="https://${ctx.req.header(
"Host"
)}/auth/confirm_email/${code}"><span code>click here</span></a>, or go to https://${ctx.req.header(
"Host"
)}/auth/confirm_email/${code}.`
)
.then(() => ctx.text("OK"))
.catch((err) => {
let e = verificationCodes.get(acc?.id || "")?.expiry
if (e) clearTimeout(e)
verificationCodes.delete(acc?.id || "")
;(ctx.get("undoCount" as never) as () => {})()
return ServeError(ctx, 500, err?.toString())
})
}
)
authRoutes.get(
"/confirm_email/:code",
requiresAccount,
noAPIAccess,
async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
let vcode = verificationCodes.get(acc.id)
if (!vcode) {
ServeError(ctx, 400, "nothing to confirm")
return
}
if (
typeof ctx.req.param("code") == "string" &&
ctx.req.param("code").toUpperCase() == vcode.code
) {
acc.email = vcode.email
Accounts.Db.save()
let e = verificationCodes.get(acc?.id || "")?.expiry
if (e) clearTimeout(e)
verificationCodes.delete(acc?.id || "")
return ctx.redirect("/")
} else {
return ServeError(ctx, 400, "invalid code")
}
}
)
authRoutes.post(
"/remove_email",
requiresAccount,
noAPIAccess,
async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
if (acc.email) {
delete acc.email
Accounts.Db.save()
return ctx.text("email detached")
} else return ServeError(ctx, 400, "email not attached")
}
)
let pwReset = new Map<
string,
{ code: string; expiry: NodeJS.Timeout; requestedAt: number }
>()
let prcIdx = new Map<string, string>()
authRoutes.post("/request_emergency_login", async (ctx) => {
if (auth.validate(getCookie(ctx, "auth") || "")) return
const body = await ctx.req.json()
if (typeof body.account != "string" || !body.account) {
ServeError(ctx, 400, "supply a username")
return
}
let acc = Accounts.getFromUsername(body.account)
if (!acc || !acc.email) {
return ServeError(
ctx,
400,
"this account either does not exist or does not have an email attached; please contact the server's admin for a reset if you would still like to access it"
)
}
let pResetCode = pwReset.get(acc.id)
if (
pResetCode &&
pResetCode.requestedAt + 15 * 60 * 1000 > Date.now()
) {
return ServeError(
ctx,
429,
`Please wait a few moments to request another emergency login.`
)
}
// delete previous if any
let e = pResetCode?.expiry
if (e) clearTimeout(e)
pwReset.delete(acc?.id || "")
prcIdx.delete(pResetCode?.code || "")
let code = generateFileId(12).toUpperCase()
// set
pwReset.set(acc.id, {
code,
expiry: setTimeout(
() => {
pwReset.delete(acc?.id || "")
prcIdx.delete(pResetCode?.code || "")
},
15 * 60 * 1000
),
requestedAt: Date.now(),
})
prcIdx.set(code, acc.id)
// this is a mess but it's fine
return sendMail(
acc.email,
`Emergency login requested for ${acc.username}`,
`<b>Hello there!</b> You are recieving this message because you forgot your password to your monofile account, <span username>${
acc.username
}</span>. To log in, please <a href="https://${ctx.req.header(
"Host"
)}/auth/emergency_login/${code}"><span code>click here</span></a>, or go to https://${ctx.req.header(
"Host"
)}/auth/emergency_login/${code}. If it doesn't appear that you are logged in after visiting this link, please try refreshing. Once you have successfully logged in, you may reset your password.`
)
.then(() => ctx.text("OK"))
.catch((err) => {
let e = pwReset.get(acc?.id || "")?.expiry
if (e) clearTimeout(e)
pwReset.delete(acc?.id || "")
prcIdx.delete(code || "")
return ServeError(ctx, 500, err?.toString())
})
})
authRoutes.get("/emergency_login/:code", async (ctx) => {
if (auth.validate(getCookie(ctx, "auth") || "")) {
return ServeError(ctx, 403, "already logged in")
}
let vcode = prcIdx.get(ctx.req.param("code"))
if (!vcode) {
return ServeError(ctx, 400, "invalid emergency login code")
}
if (typeof ctx.req.param("code") == "string" && vcode) {
login(ctx, vcode)
let e = pwReset.get(vcode)?.expiry
if (e) clearTimeout(e)
pwReset.delete(vcode)
prcIdx.delete(ctx.req.param("code"))
return ctx.redirect("/")
} else {
ServeError(ctx, 400, "invalid code")
}
})
authRoutes.post(
"/change_password",
requiresAccount,
noAPIAccess,
// Used body-parser
async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
const body = await ctx.req.json()
if (typeof body.password != "string" || body.password.length < 8) {
ServeError(ctx, 400, "password must be 8 characters or longer")
return
}
let accId = acc.id
Accounts.password.set(accId, body.password)
auth.Db.data.filter((e) => e.account == accId).forEach((v) => {
auth.invalidate(v.id)
})
if (acc.email) {
return sendMail(
acc.email,
`Your login details have been updated`,
`<b>Hello there!</b> This email is to notify you of a password change that you have initiated. You have been logged out of your devices. Thank you for using monofile.`
)
.then(() => ctx.text("OK"))
.catch((err) => {})
}
return ctx.text("password changed - logged out all sessions")
}
)
authRoutes.post(
"/logout_sessions",
requiresAccount,
noAPIAccess,
async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
let accId = acc.id
auth.Db.data.filter((e) => e.account == accId).forEach((v) => {
auth.invalidate(v.id)
})
return ctx.text("logged out all sessions")
}
)
authRoutes.get(
"/me",
requiresAccount,
requiresScopes("user"),
async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
let sessionToken = (await auth.tokenFor(ctx))!
let accId = acc.id
return ctx.json({
...acc,
sessionCount: auth.Db.data.filter(
(e) =>
e.type == "User" &&
e.account == accId &&
(e.expire == null || e.expire > Date.now())
).length,
sessionExpires: auth.Db.data.find(
(e) => e.id == sessionToken
)?.expire,
password: undefined,
email:
auth.getType(sessionToken) == "User" ||
auth.getScopes(sessionToken)?.includes("email")
? acc.email
: undefined,
})
}
)
return authRoutes
}

View file

@ -0,0 +1,12 @@
import { APIDefinition } from "../../api.js";
export default {
"name": "v0",
"baseURL": "/",
"mount": [
{ "file": "primaryApi", "to": "/" },
{ "file": "adminRoutes", "to": "/admin" },
{ "file": "authRoutes", "to": "/auth" },
{ "file": "fileApiRoutes", "to": "/files" }
]
} satisfies APIDefinition

View file

@ -0,0 +1,114 @@
import { Hono } from "hono"
import * as Accounts from "../../../lib/accounts.js"
import { writeFile } from "fs/promises"
import Files from "../../../lib/files.js"
import {
getAccount,
requiresAccount,
requiresScopes,
} from "../../../lib/middleware.js"
export let fileApiRoutes = new Hono<{
Variables: {
account: Accounts.Account
}
}>()
fileApiRoutes.use("*", getAccount)
export default function (files: Files) {
fileApiRoutes.get(
"/list",
requiresAccount,
requiresScopes("user"),
async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
if (!acc) return
let accId = acc.id
return ctx.json(
acc.files
.map((e) => {
let fp = files.db.data[e]
if (!fp) {
Accounts.files.deindex(accId, e)
return null
}
return {
...fp,
messageids: null,
owner: null,
id: e,
}
})
.filter((e) => e)
)
}
)
fileApiRoutes.post(
"/manage",
requiresScopes("manage_files"),
async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
const body = await ctx.req.json()
if (!acc) return
if (
!body.target ||
!(typeof body.target == "object") ||
body.target.length < 1
)
return
let modified = 0
body.target.forEach((e: string) => {
if (!acc.files.includes(e)) return
let fp = files.db.data[e]
switch (body.action) {
case "delete":
files.unlink(e, true)
modified++
break
case "changeFileVisibility":
if (
!["public", "anonymous", "private"].includes(
body.value
)
)
return
files.db.data[e].visibility = body.value
modified++
break
case "setTag":
if (!body.value) delete files.db.data[e].tag
else {
if (body.value.toString().length > 30) return
files.db.data[e].tag = body.value
.toString()
.toLowerCase()
}
modified++
break
}
})
return Accounts.Db.save()
.then(() => {
writeFile(
process.cwd() + "/.data/files.json",
JSON.stringify(files.db.data)
)
})
.then(() => ctx.text(`modified ${modified} files`))
.catch((err) => console.error(err))
}
)
return fileApiRoutes
}

View file

@ -0,0 +1,44 @@
import { Context, Hono } from "hono"
import * as Accounts from "../../../lib/accounts.js"
import * as auth from "../../../lib/auth.js"
import RangeParser, { type Range } from "range-parser"
import ServeError from "../../../lib/errors.js"
import Files, { WebError } from "../../../lib/files.js"
import { getAccount, mirror, requiresScopes } from "../../../lib/middleware.js"
import {Readable} from "node:stream"
import type {ReadableStream as StreamWebReadable} from "node:stream/web"
import formidable from "formidable"
import { HttpBindings } from "@hono/node-server"
import { type StatusCode } from "hono/utils/http-status"
export let primaryApi = new Hono<{
Variables: {
account: Accounts.Account
},
Bindings: HttpBindings
}>()
primaryApi.all("*", getAccount)
function fileReader(apiRoot: Hono) {
return async (ctx: Context) =>
apiRoot.fetch(
new Request(
(new URL(
`/api/v1/file/${ctx.req.param("fileId")}`, ctx.req.raw.url)).href,
ctx.req.raw
),
ctx.env
)
}
export default function (files: Files, apiRoot: Hono) {
primaryApi.get("/file/:fileId", fileReader(apiRoot))
primaryApi.get("/cpt/:fileId/*", fileReader(apiRoot))
primaryApi.post("/upload", async (ctx) =>
mirror(apiRoot, ctx, "/api/v1/file", {method: "PUT"})
)
return primaryApi
}

View file

@ -0,0 +1,127 @@
// Modules
import { type Context, Hono } from "hono"
import { getCookie, setCookie } from "hono/cookie"
// Libs
import Files from "../../../../lib/files.js"
import * as Accounts from "../../../../lib/accounts.js"
import * as auth from "../../../../lib/auth.js"
import {
assertAPI,
getAccount,
getTarget,
issuesToMessage,
login,
noAPIAccess,
requiresAccount,
requiresScopes,
scheme,
} from "../../../../lib/middleware.js"
import ServeError from "../../../../lib/errors.js"
import Configuration from "../../../../lib/config.js"
import { AccountSchemas, AuthSchemas, FileSchemas } from "../../../../lib/schemas/index.js"
import { z } from "zod"
import { BlankInput } from "hono/types"
type HonoEnv = {
Variables: {
account: Accounts.Account
target: Accounts.Account
targetToken: auth.AuthToken
parsedScheme: any
}
}
const router = new Hono<HonoEnv>()
function getTargetToken(ctx: Context<HonoEnv, "/:token", BlankInput>) {
return auth.Db.data.find(
e =>
e.account == ctx.get("target").id
&& e.id == ctx.req.param("token")
)
}
router.use(getAccount, requiresAccount, getTarget)
router.use("/", noAPIAccess) // idk if this is redundant but just in case
router.use("/:token", async (ctx,next) => {
let tok = getTargetToken(ctx)
let actingTok = auth.resolve((await auth.tokenFor(ctx))!)!
if (!tok)
return ServeError(ctx, 404, "token not found")
if (auth.getType(actingTok) != "User" && tok != actingTok)
return ServeError(ctx, 403, "cannot manage this token")
ctx.set("targetToken", tok)
return next()
})
export default function (files: Files) {
router.get("/", async (ctx) => {
return ctx.json(
auth.Db.data.filter(e => e.account == ctx.get("target").id)
)
})
router.delete(
"/",
scheme(
z.array(AuthSchemas.TokenType)
.nonempty()
.default(["User"])
.transform(e => new Set(e)),
(c) => c.req.query("type")?.split(",")
),
async (ctx) => {
let targets = auth.Db.data.filter(
e =>
e.account == ctx.get("target").id
&& ctx.get("parsedScheme").has(e.type)
)
targets.forEach(e => auth.invalidate(e.id))
return ctx.text(`deleted ${targets.length} tokens`)
}
)
router.get("/:token", async (ctx) => {
return ctx.json(ctx.get("targetToken"))
})
router.delete("/:token", async (ctx) => {
auth.invalidate(ctx.get("targetToken"))
return ctx.text(`deleted token ${ctx.get("targetToken").id}`)
})
const CreateTokenScheme =
z.object({
expire: z.number().positive().nullable(),
scopes: z.union([
z.literal("all"),
z.array(AuthSchemas.Scope).nonempty().default(["user"])
])
})
router.post(
"/",
scheme(CreateTokenScheme),
async (ctx) => {
let params = ctx.get("parsedScheme") as z.infer<typeof CreateTokenScheme>
let token = auth.create(
ctx.get("target").id,
params.expire,
"ApiKey",
params.scopes == "all"
? AuthSchemas.Scope.options
: Array.from(new Set(params.scopes))
)
return ctx.text(await auth.makeJwt(token.id))
}
)
return router
}

View file

@ -0,0 +1,396 @@
// Modules
import { type Context, Hono } from "hono"
import { getCookie, setCookie } from "hono/cookie"
// Libs
import Files from "../../../../lib/files.js"
import * as Accounts from "../../../../lib/accounts.js"
import * as auth from "../../../../lib/auth.js"
import {
accountMgmtRoute,
assertAPI,
getAccount,
getTarget,
issuesToMessage,
login,
noAPIAccess,
requiresAccount,
requiresScopes,
scheme,
verifyPoi,
} from "../../../../lib/middleware.js"
import ServeError from "../../../../lib/errors.js"
import { sendMail } from "../../../../lib/mail.js"
import * as CodeMgr from "../../../../lib/codes.js"
import Configuration from "../../../../lib/config.js"
import { AccountSchemas, FileSchemas } from "../../../../lib/schemas/index.js"
import { z } from "zod"
import * as invites from "../../../../lib/invites.js"
const router = new Hono<{
Variables: {
account: Accounts.Account
target: Accounts.Account
parsedScheme: any
}
}>()
type UserUpdateParameters = Partial<
Omit<Accounts.Account, "password"> & {
password: string
poi?: 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>`
// @Jack5079 make typings better if possible
type Validator<
T extends keyof Partial<Accounts.Account>
> =
/**
* @param actor The account performing this action
* @param target The target account for this action
* @param params Changes being patched in by the user
*/
(
actor: Accounts.Account,
target: Accounts.Account,
params: UserUpdateParameters &
{
[K in keyof Pick<
UserUpdateParameters,
T
>]-?: UserUpdateParameters[K]
},
ctx: Context
) => Accounts.Account[T] | Message
type SchemedValidator<
T extends keyof Partial<Accounts.Account>
> = {
validator: Validator<T>,
schema: z.ZodTypeAny,
noAPIAccess?: boolean,
requireProofOfIdentity?: boolean
}
const validators: {
[T in keyof Partial<Accounts.Account>]: SchemedValidator<T>
} = {
defaultFileVisibility: {
schema: FileSchemas.FileVisibility,
validator: (actor, target, params) => {
return params.defaultFileVisibility
}
},
email: {
schema: AccountSchemas.Account.shape.email.nullable(),
noAPIAccess: true,
requireProofOfIdentity: true,
validator: (actor, target, params, ctx) => {
if (!Configuration.mail.enabled) return [501, "email not enabled on instance"]
if (!params.email) {
if (target.email) {
sendMail(
target.email,
`Email disconnected`,
`<b>Hello there!</b> Your email address (<span code>${target.email}</span>) has been disconnected from the monofile account <span username>${target.username}</span>. Thank you for using monofile.`
).catch()
}
return undefined
}
if (actor.admin) return params.email || undefined
// send verification email
const tryCode = CodeMgr.code("verifyEmail", target.id, params.email)
if (!tryCode.success)
return [429, tryCode.error]
const { code } = tryCode
sendMail(
params.email,
`Hey there, ${target.username} - let's connect your email`,
`<b>Hello there!</b> You are recieving this message because you decided to link your email, <span code>${
params.email.split("@")[0]
}<span style="opacity:0.5">@${
params.email.split("@")[1]
}</span></span>, to your account, <span username>${
target.username
}</span>. If you would like to continue, please <a href="https://${ctx.req.header(
"Host"
)}/go/verify/${code.id}"><span code>click here</span></a>, or go to https://${ctx.req.header(
"Host"
)}/go/verify/${code.id}.`
)
return [200, "please check your inbox"]
},
},
password: {
schema: AccountSchemas.StringPassword,
noAPIAccess: true,
requireProofOfIdentity: true,
validator: (actor, target, params) => {
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: {
schema: AccountSchemas.Username,
noAPIAccess: true,
requireProofOfIdentity: true,
validator: (actor, target, params) => {
if (Accounts.getFromUsername(params.username))
return [400, "account with this username already exists"]
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: {
schema: z.boolean(),
validator: (actor, target, params) => {
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"]
if (params.suspension)
auth.Db.data
.filter(e => e.account == target.id)
.forEach(e => auth.invalidate(e.id))
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
}
},
}
router.use(getAccount)
router.on(
["GET","PATCH","DELETE"],
"/:user",
requiresAccount, getTarget
)
router.on(
["PATCH","DELETE"],
"/:user",
accountMgmtRoute
)
function isMessage(object: any): object is Message {
return (
Array.isArray(object) &&
object.length == 2 &&
typeof object[0] == "number" &&
typeof object[1] == "string"
)
}
type Result = [
keyof Accounts.Account,
Accounts.Account[keyof Accounts.Account],
] | Message
const BaseUserUpdateScheme = z.object(
Object.fromEntries(Object.entries(validators).filter(e => !e[1].requireProofOfIdentity).map(
([name, validator]) => [name, validator.schema.optional()]
))
)
const UserUpdateScheme = z.union([
BaseUserUpdateScheme.extend({
poi: z.undefined()
}).strict(),
BaseUserUpdateScheme.extend({
poi: z.string().uuid(),
...Object.fromEntries(Object.entries(validators).filter(e => e[1].requireProofOfIdentity).map(
([name, validator]) => [name, validator.schema.optional()]
))
}).strict()
])
export default function (files: Files) {
router.post("/", scheme(z.object({
username: AccountSchemas.Username,
password: AccountSchemas.StringPassword,
invite: z.string().max(6)
}).omit(
!Configuration.accounts.requiredForUpload
? { invite: true }
: {}
)), async (ctx) => {
const body = await ctx.req.json()
if (!ctx.get("account")?.admin) {
if (body.invite && !invites.has(body.invite))
return ServeError(ctx, 400, "invite invalid")
if (ctx.get("account"))
return ServeError(ctx, 400, "you are already logged in")
}
if (Accounts.getFromUsername(body.username)) {
return ServeError(
ctx,
400,
"account with this username already exists"
)
}
if (body.invite)
invites.use(body.invite)
return Accounts.create(body.username, body.password)
.then((account) => {
if (!ctx.get("account"))
login(ctx, account)
return ctx.text(account.id)
})
.catch((e) => {
console.error(e)
return ServeError(ctx, 500, e instanceof z.ZodError ? issuesToMessage(e.issues) : "internal server error")
})
})
router.patch(
"/:user",
scheme(
UserUpdateScheme
),
assertAPI(
ctx =>
Object.keys(ctx.get("parsedScheme"))
.some(e => validators[e as keyof typeof validators]?.noAPIAccess)
&& ctx.get("account") == ctx.get("target")
),
async (ctx) => {
const body = ctx.get("parsedScheme") as z.infer<typeof UserUpdateScheme>
const actor = ctx.get("account")
const target = ctx.get("target")
if (body.poi && !verifyPoi(target.id, body.poi))
return ServeError(ctx, 403, "invalid proof of identity provided")
let messages = (
Object.entries(body).filter(
(e) => e[0] !== "poi"
)
).map(([x, v]) => {
let validator = validators[x as keyof typeof validators]!
return [
x,
validator.validator(actor, target, body as any, ctx),
] as Result
}).map((v) => {
if (isMessage(v)) return v
target[v[0]] = v[1] as never // lol
return [200, "OK"] as Message
})
await Accounts.Db.save()
if (messages.length == 1)
return ctx.text(
...(messages[0]!.reverse() as [Message[1], Message[0]])
) // im sorry
else return ctx.json(messages)
}
)
router.delete("/:user", async (ctx) => {
let actor = ctx.get("account")
let target = ctx.get("target")
if (actor == target && !verifyPoi(actor.id, ctx.req.query("poi")))
return ServeError(ctx, 403, "invalid proof of identity provided")
auth.Db.data.filter((e) => e.account == target?.id).forEach((token) => {
auth.invalidate(token.id)
})
await Accounts.deleteAccount(target.id)
if (target.email) {
await sendMail(
target.email,
"Notice of account deletion",
`Your account, <span username>${target.username}</span>, has been removed. Thank you for using monofile.`
).catch()
return ctx.text("OK")
}
return ctx.text("account deleted")
})
router.get("/:user", async (ctx) => {
let acc = ctx.get("target")
let sessionToken = (await auth.tokenFor(ctx))!
return ctx.json({
...acc,
password: undefined,
email:
auth.getType(sessionToken) == "User" ||
auth.getScopes(sessionToken)?.includes("email")
? acc.email
: undefined,
activeSessions: auth.Db.data.filter(
(e) =>
e.type == "User" &&
e.account == acc.id &&
(e.expire == null || e.expire > Date.now())
).length,
})
})
return router
}

View file

@ -0,0 +1,85 @@
// Modules
import { type Context, Hono } from "hono"
import { getCookie, setCookie } from "hono/cookie"
// Libs
import Files from "../../../../lib/files.js"
import * as Accounts from "../../../../lib/accounts.js"
import * as auth from "../../../../lib/auth.js"
import {
assertAPI,
getAccount,
getTarget,
issuesToMessage,
login,
noAPIAccess,
requiresAccount,
requiresScopes,
requiresTarget,
scheme,
} from "../../../../lib/middleware.js"
import ServeError from "../../../../lib/errors.js"
import Configuration from "../../../../lib/config.js"
import { AccountSchemas, AuthSchemas, FileSchemas } from "../../../../lib/schemas/index.js"
import { z } from "zod"
import { BlankInput } from "hono/types"
import * as CodeMgr from "../../../../lib/codes.js"
const router = new Hono<{
Variables: {
account?: Accounts.Account
target: Accounts.Account
parsedScheme: any
}
}>()
router.use(getAccount, getTarget, requiresTarget, noAPIAccess)
const ProofCreationSchema = z.object({
password: z.string().optional(),
/*auth: AuthSchemas.2fa.any*/ // if we add 2fa...
})
export default function () {
router.get("/", async (ctx) => {
return ctx.json(["none"]) // if we add 2fa in the future, return available 2fa methods
})
router.post("/", requiresAccount, scheme(
ProofCreationSchema
), async (ctx) => {
let actor = ctx.get("account")
let target = ctx.get("target")
let body = ctx.get("parsedScheme") as z.infer<typeof ProofCreationSchema>
if (true /*(!actor || !actor.2fa)*/) {
// if there is no actor,
// or if the actor doesn't have 2fa
// check their password first
if (!Accounts.password.check(target.id, body.password||""))
return ServeError(ctx, 401, `bad password`)
}
// if actor does have 2fa in an else block here
const tryCode = CodeMgr.code(
"identityProof",
target.id,
Boolean(actor), // so that you can only log in with proofs created when logged out
5 * 60 * 1000
)
if (!tryCode.success)
return ServeError(ctx, 429, tryCode.error)
return ctx.text(tryCode.code.id)
})
return router
}

View file

@ -0,0 +1,41 @@
import type { APIDefinition } from "../../api.js";
export default {
"name": "v1",
"baseURL": "/api/v1",
"mount": [
{
"file": "account/index",
"to": "/account"
},
{
"file": "account/access",
"to": "/account/:user/access"
},
{
"file": "account/prove",
"to": "/account/:user/proveIdentity"
},
"session",
{
"file": "index",
"to": "/"
},
{
"file": "file/index",
"to": "/file"
},
{
"file": "file/individual",
"to": "/file"
},
{
"file": "/server/invites",
"to": "/server/invites"
},
{
"file": "/server/run",
"to": "/server/run"
}
]
} satisfies APIDefinition

View file

@ -0,0 +1,240 @@
import { Hono } from "hono"
import * as Accounts from "../../../../lib/accounts.js"
import * as auth from "../../../../lib/auth.js"
import RangeParser, { type Range } from "range-parser"
import ServeError from "../../../../lib/errors.js"
import Files, { WebError } from "../../../../lib/files.js"
import { getAccount, requiresAccount, requiresScopes, runtimeEvaluatedScheme, scheme } from "../../../../lib/middleware.js"
import {Readable} from "node:stream"
import type {ReadableStream as StreamWebReadable} from "node:stream/web"
import formidable from "formidable"
import { HttpBindings } from "@hono/node-server"
import pkg from "../../../../lib/package.js"
import { type StatusCode } from "hono/utils/http-status"
import { z } from "zod"
import { FileSchemas } from "../../../../lib/schemas/index.js"
import config from "../../../../lib/config.js"
import { BulkFileUpdate, BulkUnprivilegedFileUpdate } from "./schemes.js"
import { applyTagMask } from "../../../../lib/apply.js"
const router = new Hono<{
Variables: {
account: Accounts.Account,
parsedScheme: any
},
Bindings: HttpBindings
}>()
router.all("*", getAccount)
export default function(files: Files) {
router.on(
["PUT", "POST"],
"/",
requiresScopes("manage_files"),
(ctx) => { return new Promise((resolve,reject) => {
ctx.env.incoming.removeAllListeners("data") // remove hono's buffering
let errEscalated = false
function escalate(err:Error) {
if (errEscalated) return
errEscalated = true
console.error(err)
if ("httpCode" in err)
ctx.status(err.httpCode as StatusCode)
else if (err instanceof WebError)
ctx.status(err.statusCode as StatusCode)
else ctx.status(400)
resolve(ctx.body(err.message))
}
let acc = ctx.get("account") as Accounts.Account | undefined
if (!ctx.req.header("Content-Type")?.startsWith("multipart/form-data"))
return resolve(ctx.body("must be multipart/form-data", 400))
if (!ctx.req.raw.body)
return resolve(ctx.body("body must be supplied", 400))
if (config.accounts.requiredForUpload && !acc)
return resolve(ctx.body("instance requires you to be authenticated to upload files", 401))
let file = files.createWriteStream(acc?.id)
file
.on("error", escalate)
.on("finish", async () => {
if (!ctx.env.incoming.readableEnded) await new Promise(res => ctx.env.incoming.once("end", res))
file.commit()
.then(id => resolve(ctx.body(id!)))
.catch(escalate)
})
let parser = formidable({
maxFieldsSize: 65536,
maxFileSize: files.config.maxDiscordFileSize*files.config.maxDiscordFiles,
maxFiles: 1
})
let acceptNewData = true
parser.onPart = function(part) {
if (!part.originalFilename || !part.mimetype) {
parser._handlePart(part)
return
}
// lol
if (part.name == "file") {
if (!acceptNewData || file.writableEnded)
return part.emit("error", new WebError(400, "cannot set file after previously setting up another upload"))
acceptNewData = false
file.setName(part.originalFilename || "")
file.setType(part.mimetype || "")
file.on("drain", () => ctx.env.incoming.resume())
file.on("error", (err) => part.emit("error", err))
part.on("data", (data: Buffer) => {
if (!file.write(data))
ctx.env.incoming.pause()
})
part.on("end", () => file.end())
}
}
parser.on("field", async (k,v) => {
if (k == "uploadId") {
if (files.db.data[v] && ctx.req.method == "POST")
return file.destroy(new WebError(409, "file already exists"))
file.setUploadId(v)
// I'M GONNA KILL MYSELF!!!!
} else if (k == "file") {
if (!acceptNewData || file.writableEnded)
return file.destroy(new WebError(400, "cannot set file after previously setting up another upload"))
acceptNewData = false
let res = await fetch(v, {
headers: {
"user-agent": `monofile ${pkg.version} (+https://${ctx.req.header("Host")})`
}
}).catch(escalate)
if (!res) return
if (!file
.setName(
res.headers.get("Content-Disposition")
?.match(/filename="(.*)"/)?.[1]
|| v.split("/")[
v.split("/").length - 1
] || "generic"
)) return
if (res.headers.has("Content-Type"))
if (!file.setType(res.headers.get("Content-Type")!))
return
if (!res.ok) return file.destroy(new WebError(500, `got ${res.status} ${res.statusText}`))
if (!res.body) return file.destroy(new WebError(500, `Internal Server Error`))
if (
res.headers.has("Content-Length")
&& !Number.isNaN(parseInt(res.headers.get("Content-Length")!,10))
&& parseInt(res.headers.get("Content-Length")!,10) > files.config.maxDiscordFileSize*files.config.maxDiscordFiles
)
return file.destroy(new WebError(413, `file reports to be too large`))
Readable.fromWeb(res.body as StreamWebReadable)
.pipe(file)
}
})
parser.parse(ctx.env.incoming)
.catch(e => console.error(e))
parser.on('error', (err) => {
escalate(err)
if (!file.destroyed) file.destroy(err)
})
})}
)
// THIS IS SHIT!!!
router.patch("/", requiresAccount, runtimeEvaluatedScheme(
(c) => c.get("account").admin ? BulkFileUpdate : BulkUnprivilegedFileUpdate
), (ctx) => {
let actor = ctx.get("account")
let update = ctx.get("parsedScheme") as z.infer<typeof BulkFileUpdate>
let to = Array.from(new Set(update.to).values())
let todo = update.do
for (let k of to) {
if (!(k in files.db.data))
return ServeError(ctx, 404, `file ${k} doesn't exist`)
if (!actor.admin && files.db.data[k].owner != actor.id)
return ServeError(ctx, 403, `you don't own file ${k}`)
}
let applied: Record<string, string[]> = {}
if (typeof todo !== "string" && "tag" in todo)
for (let e of to) {
applied[e] = applyTagMask(
files.db.data[e].tag || [],
todo.tag as Exclude<typeof todo.tag, undefined>
)
if (applied[e].length > 5)
return ServeError(ctx, 400, `too many tags for file ID ${e}`)
}
to.forEach(
todo == "delete"
? e => files.unlink(e, true)
: e => files.apply(e, {
...todo,
...("tag" in todo ? {
tag: applied[e]
} : {})
} as Omit<typeof todo, "tag"> & { tag: string[] }, true)
)
files.db.save()
Accounts.Db.save()
return ctx.text("ok")
})
router.get("/", requiresAccount,
/*scheme(
z.object({
page: z.string().refine(e => !Number.isNaN(parseInt(e,10))),
amount: z.string().refine(e => !Number.isNaN(parseInt(e,10))),
changedOn: z.string().refine(e => !Number.isNaN(parseInt(e,10)))
}).partial(),
c=>c.req.query()
),*/ (ctx,next) => {
let queryStr = ctx.req.query()
let accId = queryStr.account
let actor = ctx.get("account")
let target = accId
? (
accId == "me"
? actor
: Accounts.resolve(accId)
)
: null
if (!actor.admin && target != actor)
return ServeError(ctx, 403, "can't control other users")
let d = Object.entries(files.db.data)
.map(([id, file]) => ({...file, messageids: undefined, id}))
.filter(e => (!target || e.owner == target.id))
return ctx.json(d)
})
return router
}

View file

@ -0,0 +1,158 @@
import { Hono } from "hono"
import * as Accounts from "../../../../lib/accounts.js"
import * as auth from "../../../../lib/auth.js"
import RangeParser, { type Range } from "range-parser"
import ServeError from "../../../../lib/errors.js"
import Files, { WebError } from "../../../../lib/files.js"
import { getAccount, mirror, requiresScopes } from "../../../../lib/middleware.js"
import {Readable} from "node:stream"
import type {ReadableStream as StreamWebReadable} from "node:stream/web"
import formidable from "formidable"
import { HttpBindings } from "@hono/node-server"
import { type StatusCode } from "hono/utils/http-status"
const router = new Hono<{
Variables: {
account: Accounts.Account
},
Bindings: HttpBindings
}>()
router.use(getAccount)
export default function(files: Files, apiRoot: Hono) {
router.get("/:id", async (ctx) => {
const fileId = ctx.req.param("id")
let acc = ctx.get("account") as Accounts.Account
let file = files.db.data[fileId]
ctx.header("Accept-Ranges", "bytes")
ctx.header("Access-Control-Allow-Origin", "*")
ctx.header("Content-Security-Policy", "sandbox allow-scripts")
if (file) {
ctx.header("Content-Disposition", `${ctx.req.query("attachment") == "1" ? "attachment" : "inline"}; filename="${encodeURI(file.filename.replaceAll("\n","\\n"))}"`)
ctx.header("ETag", file.md5)
if (file.lastModified) {
let lm = new Date(file.lastModified)
// TERRIFYING
ctx.header("Last-Modified",
`${['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][lm.getUTCDay()]}, ${lm.getUTCDate()} `
+ `${['Jan','Feb','Mar','Apr','May','Jun',"Jul",'Aug','Sep','Oct','Nov','Dec'][lm.getUTCMonth()]}`
+ ` ${lm.getUTCFullYear()} ${lm.getUTCHours().toString().padStart(2,"0")}`
+ `:${lm.getUTCMinutes().toString().padStart(2,"0")}:${lm.getUTCSeconds().toString().padStart(2,"0")} GMT`
)
}
if (file.visibility == "private") {
if (acc?.id != file.owner) {
return ServeError(ctx, 403, "you do not own this file")
}
let token = (await auth.tokenFor(ctx))!
if (
auth.getType(token) != "User" &&
auth
.getScopes(token)!
.includes("private")
) {
return ServeError(ctx, 403, "insufficient permissions")
}
}
let range: Range | undefined
ctx.header("Content-Type", file.mime)
if (file.sizeInBytes) {
ctx.header("Content-Length", file.sizeInBytes.toString())
if (file.chunkSize && ctx.req.header("Range")) {
let ranges = RangeParser(file.sizeInBytes, ctx.req.header("Range") || "")
if (ranges) {
if (typeof ranges == "number")
return ServeError(ctx, ranges == -1 ? 416 : 400, ranges == -1 ? "unsatisfiable ranges" : "invalid ranges")
if (ranges.length > 1) return ServeError(ctx, 400, "multiple ranges not supported")
range = ranges[0]
ctx.status(206)
ctx.header(
"Content-Length",
(range.end - range.start + 1).toString()
)
ctx.header(
"Content-Range",
`bytes ${range.start}-${range.end}/${file.sizeInBytes}`
)
}
}
}
if (ctx.req.method == "HEAD")
return ctx.body(null)
return files
.readFileStream(fileId, range)
.then(async (stream) => {
let rs = new ReadableStream({
start(controller) {
stream.once("end", () => controller.close())
stream.once("error", (err) => controller.error(err))
},
cancel(reason) {
stream.destroy(reason instanceof Error ? reason : new Error(reason))
}
})
stream.pipe(ctx.env.outgoing)
return new Response(rs, ctx.body(null))
})
.catch((err) => {
return ServeError(ctx, err.status, err.message)
})
} else {
return ServeError(ctx, 404, "file not found")
}
})
router.on(["PUT", "POST"], "/:id", async (ctx) => {
ctx.env.incoming.push(
`--${ctx.req.header("content-type")?.match(/boundary=(\S+)/)?.[1]}\r\n`
+ `Content-Disposition: form-data; name="uploadId"\r\n\r\n`
+ ctx.req.param("id")
+ "\r\n"
)
return apiRoot.fetch(
new Request(
(new URL(
`/api/v1/file`, ctx.req.raw.url)).href,
ctx.req.raw
),
ctx.env
)
})
router.delete("/:id", async (ctx) =>
mirror(apiRoot, ctx, "/api/v1/file", {
method: "PATCH",
body: JSON.stringify({
do: "delete",
to: [ctx.req.param("id")]
})
})
)
router.patch("/:id", async (ctx) =>
mirror(apiRoot, ctx, "/api/v1/file", {
method: "PATCH",
body: JSON.stringify({
do: await ctx.req.json(),
to: [ctx.req.param("id")]
})
})
)
return router
}

View file

@ -0,0 +1,29 @@
import { z } from "zod";
import { FileSchemas } from "../../../../lib/schemas/index.js";
export const FilePatch = FileSchemas.FilePointer
.pick({ filename: true, visibility: true })
.extend({
id: z.string(),
owner: z.string().nullable(),
tag: z.record(FileSchemas.FileTag, z.boolean())
})
.partial()
export const FileUpdate = z.union([
z.literal("delete"),
FilePatch
])
export const UnprivilegedFileUpdate = z.union([
z.literal("delete"),
FilePatch.omit({ id: true, owner: true })
])
export const BulkFileUpdate = z.object({
do: FileUpdate,
to: FileSchemas.FileId.array()
})
export const BulkUnprivilegedFileUpdate = z.object({
do: UnprivilegedFileUpdate,
to: FileSchemas.FileId.array()
})

View file

@ -0,0 +1,30 @@
import { Hono } from "hono"
import * as Accounts from "../../../lib/accounts.js"
import { HttpBindings } from "@hono/node-server"
import config, { ClientConfiguration } from "../../../lib/config.js"
import type Files from "../../../lib/files.js"
import pkg from "../../../lib/package.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.db.data).length,
totalSize: Object.values(files.db.data).filter(e => e.sizeInBytes).reduce((acc,cur)=>acc+cur.sizeInBytes!,0),
maxDiscordFiles: config.maxDiscordFiles,
maxDiscordFileSize: config.maxDiscordFileSize,
accounts: config.accounts,
mailEnabled: config.mail.enabled
} as ClientConfiguration)
)
return router
}

View file

@ -0,0 +1,40 @@
import { Hono } from "hono"
import * as Accounts from "../../../../lib/accounts.js"
import * as auth from "../../../../lib/auth.js"
import { HttpBindings } from "@hono/node-server"
import config, { ClientConfiguration } from "../../../../lib/config.js"
import type Files from "../../../../lib/files.js"
import { getAccount, requiresAccount, requiresAdmin } from "../../../../lib/middleware.js"
import { Writable } from "node:stream"
import { Db, make, use } from "../../../../lib/invites.js"
import ServeError from "../../../../lib/errors.js"
const router = new Hono<{
Variables: {
account: Accounts.Account
},
Bindings: HttpBindings
}>()
router.use(getAccount, requiresAccount, requiresAdmin)
export default function(files: Files) {
// api is structured like this
// in case invites become more complicated
// in the future
// if and when the api does become more complex
// i'll probably add GET /server/invites/:invite etc
router.post("/", async (ctx) => ctx.json({id: make()}))
router.get("/", async (ctx) => ctx.json(Db.data.map(e => ({id: e}))))
router.delete("/:invite", async (ctx) => {
if (use(ctx.req.param("invite"))) {
return ctx.json({id: ctx.req.param("invite")})
} else {
return ServeError(ctx, 404, "invalid invite")
}
})
return router
}

View file

@ -0,0 +1,68 @@
import { Hono } from "hono"
import * as Accounts from "../../../../lib/accounts.js"
import * as auth from "../../../../lib/auth.js"
import { HttpBindings } from "@hono/node-server"
import config, { ClientConfiguration } from "../../../../lib/config.js"
import type Files from "../../../../lib/files.js"
import { getAccount, requiresAccount, requiresAdmin } from "../../../../lib/middleware.js"
import { Writable } from "node:stream"
const router = new Hono<{
Variables: {
account: Accounts.Account
},
Bindings: HttpBindings
}>()
router.use(getAccount, requiresAccount, requiresAdmin)
class Collect extends Writable {
collected: {t: number, packet: Buffer}[] = []
_write(data: Buffer, _: string, cb: () => void) {
this.collected.push({t: Date.now(), packet: data})
cb();
}
}
class VirtualConsole extends console.Console {
readonly stdout: Collect
readonly stderr: Collect
constructor() {
const stdout = new Collect(), stderr = new Collect()
super(stdout, stderr)
this.stdout = stdout, this.stderr = stderr
}
}
export default function(files: Files) {
router.post("/", async (ctx) => {
let vconsole = new VirtualConsole()
let evaluated
try {
let fn = new Function(
"accounts",
"auth",
"files",
"console",
await ctx.req.text()
)
evaluated = await fn(Accounts, auth, files, vconsole)
} catch (err) {
vconsole.error(err)
}
return ctx.json({
stdout: vconsole.stdout.collected,
stderr: vconsole.stderr.collected,
evaluated
})
})
return router
}

View file

@ -0,0 +1,70 @@
// Modules
import { Hono } from "hono"
import { getCookie, setCookie } from "hono/cookie"
// Libs
import Files from "../../../lib/files.js"
import * as Accounts from "../../../lib/accounts.js"
import * as auth from "../../../lib/auth.js"
import {
getAccount,
login,
mirror,
requiresAccount,
scheme
} from "../../../lib/middleware.js"
import ServeError from "../../../lib/errors.js"
import { AccountSchemas } from "../../../lib/schemas/index.js"
import { z } from "zod"
const router = new Hono<{
Variables: {
account: Accounts.Account
}
}>()
router.use(getAccount)
export default function (files: Files, apiRoot: Hono) {
router.post("/",scheme(z.object({
username: AccountSchemas.Username,
password: AccountSchemas.StringPassword
})), async (ctx) => {
const body = await ctx.req.json()
if (ctx.get("account"))
return ServeError(ctx, 400, "you are already logged in")
const account = Accounts.getFromUsername(body.username)
if (!account || !Accounts.password.check(account.id, body.password)) {
return ServeError(ctx, 400, "username or password incorrect")
}
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)
return ctx.text("logged in")
})
router.on(
["GET","DELETE"],
"/",
requiresAccount,
async ctx =>
mirror(apiRoot, ctx, `/api/v1/account/me/access/${await auth.tokenFor(ctx)!}`, {})
)
return router
}

View file

@ -0,0 +1,7 @@
import { APIDefinition } from "../../api.js";
export default {
"name": "web",
"baseURL": "/",
"mount": [{ "file": "preview", "to": "/download" }, "go"]
} satisfies APIDefinition

View file

@ -0,0 +1,40 @@
import fs from "fs/promises"
import bytes from "bytes"
import ServeError from "../../../lib/errors.js"
import * as Accounts from "../../../lib/accounts.js"
import type Files from "../../../lib/files.js"
import * as CodeMgr from "../../../lib/codes.js"
import { Hono } from "hono"
import { getAccount, login } from "../../../lib/middleware.js"
export let router = new Hono<{
Variables: {
account: Accounts.Account
}
}>()
export default function (files: Files) {
router.get("/verify/:code", getAccount, async (ctx) => {
let currentAccount = ctx.get("account")
let code = CodeMgr.codes.verifyEmail.byId.get(ctx.req.param("code"))
if (code) {
if (currentAccount != undefined && !code.check(currentAccount.id)) {
return ServeError(ctx, 403, "you are logged in on a different account")
}
if (!currentAccount) {
login(ctx, code.for)
let ac = Accounts.getFromId(code.for)
if (ac) currentAccount = ac
else return ServeError(ctx, 401, "could not locate account")
}
currentAccount.email = code.data
await Accounts.Db.save()
return ctx.redirect('/')
} else return ServeError(ctx, 404, "code not found")
})
return router
}

View file

@ -0,0 +1,114 @@
import fs from "fs/promises"
import bytes from "bytes"
import ServeError from "../../../lib/errors.js"
import * as Accounts from "../../../lib/accounts.js"
import type Files from "../../../lib/files.js"
import pkg from "../../../lib/package.js"
import { Hono } from "hono"
import { getAccount } from "../../../lib/middleware.js"
export let router = new Hono<{
Variables: {
account: Accounts.Account
}
}>()
export default function (files: Files) {
router.get("/:fileId", getAccount, async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
const fileId = ctx.req.param("fileId")
const host = ctx.req.header("Host")
const file = files.db.data[fileId]
if (file) {
if (file.visibility == "private" && acc?.id != file.owner) {
return ServeError(ctx, 403, "you do not own this file")
}
const template = await fs
.readFile(process.cwd() + "/dist/download.html", "utf8")
.catch(() => {
throw ctx.status(500)
})
let fileOwner = file.owner
? Accounts.getFromId(file.owner)
: undefined
return ctx.html(
template
.replaceAll("$FileId", fileId)
.replaceAll("$Version", pkg.version)
.replaceAll(
"$FileSize",
file.sizeInBytes
? bytes(file.sizeInBytes)
: "[File size unknown]"
)
.replaceAll(
"$FileName",
file.filename
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
)
.replace(
"<!--metaTags-->",
(file.mime.startsWith("image/")
? `<meta name="og:image" content="https://${host}/file/${fileId}" />`
: file.mime.startsWith("video/")
? `<meta property="og:video:url" content="https://${host}/cpt/${fileId}/video.${
file.mime.split("/")[1] == "quicktime"
? "mov"
: file.mime.split("/")[1]
}" />
<meta property="og:video:secure_url" content="https://${host}/cpt/${fileId}/video.${
file.mime.split("/")[1] == "quicktime"
? "mov"
: file.mime.split("/")[1]
}" />
<meta property="og:type" content="video.other">
<!-- honestly probably good enough for now -->
<meta property="twitter:image" content="0">` +
// quick lazy fix as a fallback
// maybe i'll improve this later, but probably not.
((file.sizeInBytes || 0) >= 26214400
? `
<meta property="og:video:width" content="1280">
<meta property="og:video:height" content="720">`
: "")
: "") +
(fileOwner?.settings?.links?.largeImage &&
file.visibility != "anonymous" &&
file.mime.startsWith("image/")
? `<meta name="twitter:card" content="summary_large_image">`
: "") +
`\n<meta name="theme-color" content="${
fileOwner?.settings?.links.color &&
file.visibility != "anonymous" &&
(ctx.req.header("user-agent") || "").includes(
"Discordbot"
)
? `#${fileOwner?.settings?.links.color}`
: "rgb(30, 33, 36)"
}">`
)
.replace(
"<!--preview-->",
file.mime.startsWith("image/")
? `<div style="min-height:10px"></div><img src="/file/${fileId}" />`
: file.mime.startsWith("video/")
? `<div style="min-height:10px"></div><video src="/file/${fileId}" controls></video>`
: file.mime.startsWith("audio/")
? `<div style="min-height:10px"></div><audio src="/file/${fileId}" controls></audio>`
: ""
)
.replaceAll(
"$Uploader",
!file.owner || file.visibility == "anonymous"
? "Anonymous"
: `@${fileOwner?.username || "Deleted User"}`
)
)
} else return ServeError(ctx, 404, "file not found")
})
return router
}

View file

@ -1,465 +0,0 @@
import bodyParser from "body-parser";
import { Router } from "express";
import * as Accounts from "../lib/accounts";
import * as auth from "../lib/auth";
import { sendMail } from "../lib/mail";
import { getAccount, noAPIAccess, requiresAccount, requiresPermissions } from "../lib/middleware"
import { accountRatelimit } from "../lib/ratelimit"
import ServeError from "../lib/errors";
import Files, { FileVisibility, generateFileId, id_check_regex } from "../lib/files";
import { writeFile } from "fs";
let parser = bodyParser.json({
type: ["text/plain","application/json"]
})
export let authRoutes = Router();
authRoutes.use(getAccount)
let config = require(`${process.cwd()}/config.json`)
let files:Files
export function setFilesObj(newFiles:Files) {
files = newFiles
}
authRoutes.post("/login", parser, (req,res) => {
if (typeof req.body.username != "string" || typeof req.body.password != "string") {
ServeError(res,400,"please provide a username or password")
return
}
if (auth.validate(req.cookies.auth)) return
/*
check if account exists
*/
let acc = Accounts.getFromUsername(req.body.username)
if (!acc) {
ServeError(res,401,"username or password incorrect")
return
}
if (!Accounts.password.check(acc.id,req.body.password)) {
ServeError(res,401,"username or password incorrect")
return
}
/*
assign token
*/
res.cookie("auth",auth.create(acc.id,(3*24*60*60*1000)))
res.status(200)
res.end()
})
authRoutes.post("/create", parser, (req,res) => {
if (!config.accounts.registrationEnabled) {
ServeError(res,403,"account registration disabled")
return
}
if (auth.validate(req.cookies.auth)) return
if (typeof req.body.username != "string" || typeof req.body.password != "string") {
ServeError(res,400,"please provide a username or password")
return
}
/*
check if account exists
*/
let acc = Accounts.getFromUsername(req.body.username)
if (acc) {
ServeError(res,400,"account with this username already exists")
return
}
if (req.body.username.length < 3 || req.body.username.length > 20) {
ServeError(res,400,"username must be over or equal to 3 characters or under or equal to 20 characters in length")
return
}
if ((req.body.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] != req.body.username) {
ServeError(res,400,"username contains invalid characters")
return
}
if (req.body.password.length < 8) {
ServeError(res,400,"password must be 8 characters or longer")
return
}
Accounts.create(req.body.username,req.body.password)
.then((newAcc) => {
/*
assign token
*/
res.cookie("auth",auth.create(newAcc,(3*24*60*60*1000)))
res.status(200)
res.end()
})
.catch(() => {
ServeError(res,500,"internal server error")
})
})
authRoutes.post("/logout", (req,res) => {
if (!auth.validate(req.cookies.auth)) {
ServeError(res, 401, "not logged in")
return
}
auth.invalidate(req.cookies.auth)
res.send("logged out")
})
authRoutes.post("/dfv", requiresAccount, requiresPermissions("manage"), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (['public','private','anonymous'].includes(req.body.defaultFileVisibility)) {
acc.defaultFileVisibility = req.body.defaultFileVisibility
Accounts.save()
res.send(`dfv has been set to ${acc.defaultFileVisibility}`)
} else {
res.status(400)
res.send("invalid dfv")
}
})
authRoutes.post("/customcss", requiresAccount, requiresPermissions("customize"), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.fileId != "string") req.body.fileId = undefined;
if (
!req.body.fileId
|| (req.body.fileId.match(id_check_regex) == req.body.fileId
&& req.body.fileId.length <= config.maxUploadIdLength)
) {
acc.customCSS = req.body.fileId || undefined
if (!req.body.fileId) delete acc.customCSS
Accounts.save()
res.send(`custom css saved`)
} else {
res.status(400)
res.send("invalid fileid")
}
})
authRoutes.post("/embedcolor", requiresAccount, requiresPermissions("customize"), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.color != "string") req.body.color = undefined;
if (
!req.body.color
|| (req.body.color.toLowerCase().match(/[a-f0-9]+/) == req.body.color.toLowerCase())
&& req.body.color.length == 6
) {
if (!acc.embed) acc.embed = {}
acc.embed.color = req.body.color || undefined
if (!req.body.color) delete acc.embed.color
Accounts.save()
res.send(`custom embed color saved`)
} else {
res.status(400)
res.send("invalid hex code")
}
})
authRoutes.post("/embedsize", requiresAccount, requiresPermissions("customize"), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.largeImage != "boolean") req.body.color = false;
if (!acc.embed) acc.embed = {}
acc.embed.largeImage = req.body.largeImage
if (!req.body.largeImage) delete acc.embed.largeImage
Accounts.save()
res.send(`custom embed image size saved`)
})
authRoutes.post("/delete_account", requiresAccount, noAPIAccess, parser, async (req,res) => {
let acc = res.locals.acc as Accounts.Account
let accId = acc.id
auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
auth.invalidate(v.token)
})
let cpl = () => Accounts.deleteAccount(accId).then(_ => res.send("account deleted"))
if (req.body.deleteFiles) {
let f = acc.files.map(e=>e) // make shallow copy so that iterating over it doesnt Die
for (let v of f) {
files.unlink(v,true).catch(err => console.error(err))
}
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(files.files), (err) => {
if (err) console.log(err)
cpl()
})
} else cpl()
})
authRoutes.post("/change_username", requiresAccount, noAPIAccess, parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.username != "string" || req.body.username.length < 3 || req.body.username.length > 20) {
ServeError(res,400,"username must be between 3 and 20 characters in length")
return
}
let _acc = Accounts.getFromUsername(req.body.username)
if (_acc) {
ServeError(res,400,"account with this username already exists")
return
}
if ((req.body.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] != req.body.username) {
ServeError(res,400,"username contains invalid characters")
return
}
acc.username = req.body.username
Accounts.save()
if (acc.email) {
sendMail(acc.email, `Your login details have been updated`, `<b>Hello there!</b> Your username has been updated to <span username>${req.body.username}</span>. Please update your devices accordingly. Thank you for using monofile.`).then(() => {
res.send("OK")
}).catch((err) => {})
}
res.send("username changed")
})
// shit way to do this but...
let verificationCodes = new Map<string, {code: string, email: string, expiry: NodeJS.Timeout}>()
authRoutes.post("/request_email_change", requiresAccount, noAPIAccess, accountRatelimit({ requests: 4, per: 60*60*1000 }), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.email != "string" || !req.body.email) {
ServeError(res,400, "supply an email")
return
}
let vcode = verificationCodes.get(acc.id)
// delete previous if any
let e = vcode?.expiry
if (e) clearTimeout(e)
verificationCodes.delete(acc?.id||"")
let code = generateFileId(12).toUpperCase()
// set
verificationCodes.set(acc.id, {
code,
email: req.body.email,
expiry: setTimeout( () => verificationCodes.delete(acc?.id||""), 15*60*1000)
})
// this is a mess but it's fine
sendMail(req.body.email, `Hey there, ${acc.username} - let's connect your email`, `<b>Hello there!</b> You are recieving this message because you decided to link your email, <span code>${req.body.email.split("@")[0]}<span style="opacity:0.5">@${req.body.email.split("@")[1]}</span></span>, to your account, <span username>${acc.username}</span>. If you would like to continue, please <a href="https://${req.header("Host")}/auth/confirm_email/${code}"><span code>click here</span></a>, or go to https://${req.header("Host")}/auth/confirm_email/${code}.`).then(() => {
res.send("OK")
}).catch((err) => {
let e = verificationCodes.get(acc?.id||"")?.expiry
if (e) clearTimeout(e)
verificationCodes.delete(acc?.id||"")
res.locals.undoCount();
ServeError(res, 500, err?.toString())
})
})
authRoutes.get("/confirm_email/:code", requiresAccount, noAPIAccess, (req,res) => {
let acc = res.locals.acc as Accounts.Account
let vcode = verificationCodes.get(acc.id)
if (!vcode) { ServeError(res, 400, "nothing to confirm"); return }
if (typeof req.params.code == "string" && req.params.code.toUpperCase() == vcode.code) {
acc.email = vcode.email
Accounts.save();
let e = verificationCodes.get(acc?.id||"")?.expiry
if (e) clearTimeout(e)
verificationCodes.delete(acc?.id||"")
res.redirect("/")
} else {
ServeError(res, 400, "invalid code")
}
})
authRoutes.post("/remove_email", requiresAccount, noAPIAccess, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (acc.email) {
delete acc.email;
Accounts.save()
res.send("email detached")
}
else ServeError(res, 400, "email not attached")
})
let pwReset = new Map<string, {code: string, expiry: NodeJS.Timeout, requestedAt:number}>()
let prcIdx = new Map<string, string>()
authRoutes.post("/request_emergency_login", parser, (req,res) => {
if (auth.validate(req.cookies.auth || "")) return
if (typeof req.body.account != "string" || !req.body.account) {
ServeError(res,400, "supply a username")
return
}
let acc = Accounts.getFromUsername(req.body.account)
if (!acc || !acc.email) {
ServeError(res, 400, "this account either does not exist or does not have an email attached; please contact the server's admin for a reset if you would still like to access it")
return
}
let pResetCode = pwReset.get(acc.id)
if (pResetCode && pResetCode.requestedAt+(15*60*1000) > Date.now()) {
ServeError(res, 429, `Please wait a few moments to request another emergency login.`)
return
}
// delete previous if any
let e = pResetCode?.expiry
if (e) clearTimeout(e)
pwReset.delete(acc?.id||"")
prcIdx.delete(pResetCode?.code||"")
let code = generateFileId(12).toUpperCase()
// set
pwReset.set(acc.id, {
code,
expiry: setTimeout( () => { pwReset.delete(acc?.id||""); prcIdx.delete(pResetCode?.code||"") }, 15*60*1000),
requestedAt: Date.now()
})
prcIdx.set(code, acc.id)
// this is a mess but it's fine
sendMail(acc.email, `Emergency login requested for ${acc.username}`, `<b>Hello there!</b> You are recieving this message because you forgot your password to your monofile account, <span username>${acc.username}</span>. To log in, please <a href="https://${req.header("Host")}/auth/emergency_login/${code}"><span code>click here</span></a>, or go to https://${req.header("Host")}/auth/emergency_login/${code}. If it doesn't appear that you are logged in after visiting this link, please try refreshing. Once you have successfully logged in, you may reset your password.`).then(() => {
res.send("OK")
}).catch((err) => {
let e = pwReset.get(acc?.id||"")?.expiry
if (e) clearTimeout(e)
pwReset.delete(acc?.id||"")
prcIdx.delete(code||"")
ServeError(res, 500, err?.toString())
})
})
authRoutes.get("/emergency_login/:code", (req,res) => {
if (auth.validate(req.cookies.auth || "")) {
ServeError(res, 403, "already logged in")
return
}
let vcode = prcIdx.get(req.params.code)
if (!vcode) { ServeError(res, 400, "invalid emergency login code"); return }
if (typeof req.params.code == "string" && vcode) {
res.cookie("auth",auth.create(vcode,(3*24*60*60*1000)))
res.redirect("/")
let e = pwReset.get(vcode)?.expiry
if (e) clearTimeout(e)
pwReset.delete(vcode)
prcIdx.delete(req.params.code)
} else {
ServeError(res, 400, "invalid code")
}
})
authRoutes.post("/change_password", requiresAccount, noAPIAccess, parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.password != "string" || req.body.password.length < 8) {
ServeError(res,400,"password must be 8 characters or longer")
return
}
let accId = acc.id
Accounts.password.set(accId,req.body.password)
auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
auth.invalidate(v.token)
})
if (acc.email) {
sendMail(acc.email, `Your login details have been updated`, `<b>Hello there!</b> This email is to notify you of a password change that you have initiated. You have been logged out of your devices. Thank you for using monofile.`).then(() => {
res.send("OK")
}).catch((err) => {})
}
res.send("password changed - logged out all sessions")
})
authRoutes.post("/logout_sessions", requiresAccount, noAPIAccess, (req,res) => {
let acc = res.locals.acc as Accounts.Account
let accId = acc.id
auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
auth.invalidate(v.token)
})
res.send("logged out all sessions")
})
authRoutes.get("/me", requiresAccount, requiresPermissions("user"), (req,res) => {
let acc = res.locals.acc as Accounts.Account
let sessionToken = auth.tokenFor(req)
let accId = acc.id
res.send({
...acc,
sessionCount: auth.AuthTokens.filter(e => e.type != "App" && e.account == accId && (e.expire > Date.now() || !e.expire)).length,
sessionExpires: auth.AuthTokens.find(e => e.token == sessionToken)?.expire,
password: undefined,
email:
auth.getType(sessionToken) == "User" || auth.getPermissions(sessionToken)?.includes("email")
? acc.email
: undefined
})
})
authRoutes.get("/customCSS", (req,res) => {
let acc = res.locals.acc
if (acc?.customCSS) res.redirect(`/file/${acc.customCSS}`)
else res.send("")
})

View file

@ -1,97 +0,0 @@
import bodyParser from "body-parser";
import { Router } from "express";
import * as Accounts from "../lib/accounts";
import * as auth from "../lib/auth";
import bytes from "bytes"
import {writeFile} from "fs";
import ServeError from "../lib/errors";
import Files from "../lib/files";
import { getAccount, requiresAccount, requiresPermissions } from "../lib/middleware";
let parser = bodyParser.json({
type: ["text/plain","application/json"]
})
export let fileApiRoutes = Router();
let files:Files
export function setFilesObj(newFiles:Files) {
files = newFiles
}
let config = require(`${process.cwd()}/config.json`)
fileApiRoutes.use(getAccount);
fileApiRoutes.get("/list", requiresAccount, requiresPermissions("user"), (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (!acc) return
let accId = acc.id
res.send(acc.files.map((e) => {
let fp = files.getFilePointer(e)
if (!fp) { Accounts.files.deindex(accId, e); return null }
return {
...fp,
messageids: null,
owner: null,
id:e
}
}).filter(e=>e))
})
fileApiRoutes.post("/manage", parser, requiresPermissions("manage"), (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (!acc) return
if (!req.body.target || !(typeof req.body.target == "object") || req.body.target.length < 1) return
let modified = 0
req.body.target.forEach((e:string) => {
if (!acc.files.includes(e)) return
let fp = files.getFilePointer(e)
if (fp.reserved) {
return
}
switch( req.body.action ) {
case "delete":
files.unlink(e, true)
modified++;
break;
case "changeFileVisibility":
if (!["public","anonymous","private"].includes(req.body.value)) return;
files.files[e].visibility = req.body.value;
modified++;
break;
case "setTag":
if (!req.body.value) delete files.files[e].tag
else {
if (req.body.value.toString().length > 30) return
files.files[e].tag = req.body.value.toString().toLowerCase()
}
modified++;
break;
}
})
Accounts.save().then(() => {
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(files.files), (err) => {
if (err) console.log(err)
res.contentType("text/plain")
res.send(`modified ${modified} files`)
})
}).catch((err) => console.error(err))
})

View file

@ -1,181 +0,0 @@
import bodyParser from "body-parser";
import express, { Router } from "express";
import * as Accounts from "../lib/accounts";
import * as auth from "../lib/auth";
import axios, { AxiosResponse } from "axios"
import { type Range } from "range-parser";
import multer, {memoryStorage} from "multer"
import ServeError from "../lib/errors";
import Files from "../lib/files";
import { getAccount, requiresPermissions } from "../lib/middleware";
let parser = bodyParser.json({
type: ["text/plain","application/json"]
})
export let primaryApi = Router();
let files:Files
export function setFilesObj(newFiles:Files) {
files = newFiles
}
const multerSetup = multer({storage:memoryStorage()})
let config = require(`${process.cwd()}/config.json`)
primaryApi.use(getAccount);
primaryApi.get(["/file/:fileId", "/cpt/:fileId/*", "/:fileId"], async (req:express.Request,res:express.Response) => {
let acc = res.locals.acc as Accounts.Account
let file = files.getFilePointer(req.params.fileId)
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Content-Security-Policy","sandbox allow-scripts")
if (req.query.attachment == "1") res.setHeader("Content-Disposition", "attachment")
if (file) {
if (file.visibility == "private" && acc?.id != file.owner) {
ServeError(res,403,"you do not own this file")
return
}
let range: Range | undefined
res.setHeader("Content-Type",file.mime)
if (file.sizeInBytes) {
res.setHeader("Content-Length",file.sizeInBytes)
if (file.chunkSize) {
let rng = req.range(file.sizeInBytes)
if (rng) {
// error handling
if (typeof rng == "number") {
res.status(rng == -1 ? 416 : 400).send()
return
}
if (rng.type != "bytes") {
res.status(400).send();
return
}
// set ranges var
let rngs = Array.from(rng)
if (rngs.length != 1) { res.status(400).send(); return }
range = rngs[0]
}
}
}
// supports ranges
files.readFileStream(req.params.fileId, range).then(async stream => {
if (range) {
res.status(206)
res.header("Content-Length", (range.end-range.start + 1).toString())
res.header("Content-Range", `bytes ${range.start}-${range.end}/${file.sizeInBytes}`)
}
stream.pipe(res)
}).catch((err) => {
ServeError(res,err.status,err.message)
})
} else {
ServeError(res, 404, "file not found")
}
})
primaryApi.head(["/file/:fileId", "/cpt/:fileId/*", "/:fileId"], (req: express.Request, res:express.Response) => {
let file = files.getFilePointer(req.params.fileId)
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Content-Security-Policy","sandbox allow-scripts")
if (req.query.attachment == "1") res.setHeader("Content-Disposition", "attachment")
if (!file) {
res.status(404)
res.send()
} else {
res.setHeader("Content-Type",file.mime)
if (file.sizeInBytes) {
res.setHeader("Content-Length",file.sizeInBytes)
}
if (file.chunkSize) {
res.setHeader("Accept-Ranges", "bytes")
}
}
})
// upload handlers
primaryApi.post("/upload", requiresPermissions("upload"), multerSetup.single('file'), async (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (req.file) {
try {
let prm = req.header("monofile-params")
let params:{[key:string]:any} = {}
if (prm) {
params = JSON.parse(prm)
}
files.uploadFile({
owner: acc?.id,
uploadId:params.uploadId,
name:req.file.originalname,
mime:req.file.mimetype
},req.file.buffer)
.then((uID) => res.send(uID))
.catch((stat) => {
res.status(stat.status);
res.send(`[err] ${stat.message}`)
})
} catch {
res.status(400)
res.send("[err] bad request")
}
} else {
res.status(400)
res.send("[err] bad request")
}
})
primaryApi.post("/clone", requiresPermissions("upload"), bodyParser.json({type: ["text/plain","application/json"]}) ,(req,res) => {
let acc = res.locals.acc as Accounts.Account
try {
axios.get(req.body.url,{responseType:"arraybuffer"}).then((data:AxiosResponse) => {
files.uploadFile({
owner: acc?.id,
name:req.body.url.split("/")[req.body.url.split("/").length-1] || "generic",
mime:data.headers["content-type"],
uploadId:req.body.uploadId
},Buffer.from(data.data))
.then((uID) => res.send(uID))
.catch((stat) => {
res.status(stat.status);
res.send(`[err] ${stat.message}`)
})
}).catch((err) => {
console.log(err)
res.status(400)
res.send(`[err] failed to fetch data`)
})
} catch {
res.status(500)
res.send("[err] an error occured")
}
})

112
src/server/tools/cli.ts Normal file
View file

@ -0,0 +1,112 @@
import fs from "fs"
import { stat } from "fs/promises"
import Files from "../lib/files.js"
import { program } from "commander"
import { basename } from "path"
import { Writable } from "node:stream"
import config from "../lib/config.js"
import pkg from "../lib/package.js"
import { fileURLToPath } from "url"
import { dirname } from "path"
// init data
const __dirname = dirname(fileURLToPath(import.meta.url))
if (!fs.existsSync(__dirname + "/../../../.data/"))
fs.mkdirSync(__dirname + "/../../../.data/")
// discord
let files = new Files(config)
program
.name("monocli")
.description("Quickly run monofile to execute a query or so")
.version(pkg.version)
program
.command("list")
.alias("ls")
.description("List files in the database")
.action(() => {
Object.keys(files.db.data).forEach((e) => console.log(e))
})
program
.command("download")
.alias("dl")
.description("Download a file from the database")
.argument("<id>", "ID of the file you'd like to download")
.option("-o, --output <path>", "Folder or filename to output to")
.action(async (id, options) => {
await new Promise<void>((resolve) => setTimeout(() => resolve(), 1000))
let fp = files.db.data[id]
if (!fp) throw `file ${id} not found`
let out = (options.output as string) || `./`
if (fs.existsSync(out) && (await stat(out)).isDirectory())
out = `${out.replace(/\/+$/, "")}/${fp.filename}`
let filestream = await files.readFileStream(id)
let prog = 0
filestream.on("data", (dt) => {
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)`
)
})
filestream.pipe(fs.createWriteStream(out))
})
program
.command("upload")
.alias("up")
.description("Upload a file to the instance")
.argument("<file>", "Path to the file you'd like to upload")
.option("-id, --fileid <id>", "Custom file ID to use")
.action(async (file, options) => {
await new Promise<void>((resolve) => setTimeout(() => resolve(), 1000))
if (!(fs.existsSync(file) && (await stat(file)).isFile()))
throw `${file} is not a file`
let writable = files.createWriteStream()
writable.setName(basename(file))?.setType("application/octet-stream")
if (options.id) writable.setUploadId(options.id)
if (!(writable instanceof Writable))
throw JSON.stringify(writable, null, 3)
console.log(`started: ${file}`)
writable.on("drain", () => {
console.log("Drained")
})
writable.on("finish", async () => {
console.log("Finished!")
console.log(`ID: ${await writable.commit()}`)
})
writable.on("pipe", () => {
console.log("Piped")
})
writable.on("error", (e) => {
console.error(e)
})
writable.on("close", () => {
console.log("Closed.")
})
;(await fs.createReadStream(file)).pipe(writable)
})
program.parse()

107
src/server/tsconfig.json Normal file
View file

@ -0,0 +1,107 @@
{
"include":["**/*"],
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "nodenext", /* Specify what module code is generated. */
//"rootDir": "./src/", /* Specify the root folder within your source files. */
"moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
"resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "../../out/server", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"references": [
{ "path": "../../tsconfig.json" }
]
}

View file

@ -1,4 +1,4 @@
.pulldown_display[name=accounts] {
.pulldown_display[data-name=accounts] {
.notLoggedIn {
.container_div {
position:absolute;
@ -184,4 +184,42 @@
}
}
}
}
@keyframes bounce {
0% {
top: 0.25em;
}/*
25% {
top: 0.25em;
}
75% {
top: -0.25em;
}*/
100% {
top: -0.25em;
}
}
.loader {
i {
font-style: normal;
position: relative;
animation-name: bounce;
animation-duration: 500ms;
animation-iteration-count: infinite;
animation-direction: alternate;
top:0.25em;
&:nth-of-type(1) {
animation-delay: 0ms;
}
&:nth-of-type(2) {
animation-delay: 125ms;
}
&:nth-of-type(3) {
animation-delay: 250ms;
}
}
}

View file

@ -1,4 +1,4 @@
.pulldown_display[name=files] {
.pulldown_display[data-name=files] {
.notLoggedIn {
position:absolute;
top:50%;

View file

@ -1,4 +1,4 @@
.pulldown_display[name=help] {
.pulldown_display[data-name=help] {
overflow-y:auto;

View file

@ -13,7 +13,7 @@
span {
position:relative;
&._add_files_txt {
&.add_files_txt {
font-size:16px;
top:-4px;
left:10px;
@ -29,7 +29,7 @@
@media screen and (max-width:500px) {
font-size: 40px;
span._add_files_txt {
span.add_files_txt {
font-size:20px;
top:-6px;
left:10px;
@ -45,7 +45,7 @@
flex-direction:row;
column-gap:10px;
button, input[type=text] {
button, input[type=text], input[type=submit] {
background-color:#333333;
color:#DDDDDD;
border:none;
@ -63,7 +63,7 @@
}
}
button {
button, input[type=submit] {
cursor:pointer;
&:hover {

View file

@ -50,7 +50,7 @@
overflow:auto;
}
button {
button, input[type=submit] {
cursor:pointer;
background-color:#393939;
color:#DDDDDD;

View file

@ -1,19 +1,13 @@
<script>
<script lang="ts">
import { onMount } from "svelte";
import Topbar from "./elem/Topbar.svelte";
import PulldownManager from "./elem/PulldownManager.svelte";
import UploadWindow from "./elem/UploadWindow.svelte";
import { pulldownManager } from "./elem/stores.mjs";
import { pulldownManager } from "./elem/stores.js";
/**
* @type Topbar
*/
let topbar;
let topbar: Topbar;
/**
* @type PulldownManager
*/
let pulldown;
let pulldown: PulldownManager;
onMount(() => {
pulldownManager.set(pulldown)

View file

@ -1,4 +1,4 @@
<script context="module">
<script context="module" lang="ts">
import { writable } from "svelte/store";
// can't find a better way to do this
@ -13,10 +13,10 @@
.set("help",Help)
.set("files",Files)
export const pulldownOpen = writable(false);
export const pulldownOpen = writable<string|false>(false);
</script>
<script>
<script lang="ts">
import { onMount } from "svelte";
import { fade, scale } from "svelte/transition";
@ -24,7 +24,7 @@
return $pulldownOpen
}
export function openPulldown(name) {
export function openPulldown(name: string) {
pulldownOpen.set(name)
}

View file

@ -1,14 +1,11 @@
<script>
<script lang="ts">
import { circOut } from "svelte/easing";
import { scale } from "svelte/transition";
import PulldownManager, {pulldownOpen} from "./PulldownManager.svelte";
import { account } from "./stores.mjs";
import { _void } from "./transition/_void";
import { account } from "./stores.js";
import { _void } from "./transition/_void.js";
/**
* @type PulldownManager
*/
export let pulldown;
export let pulldown: PulldownManager;
</script>
<div id="topbar">
@ -23,7 +20,7 @@
<!-- too lazy to make this better -->
<button class="menuBtn" on:click={() => pulldown.openPulldown("files")}>files</button>
<button class="menuBtn" on:click={() => pulldown.openPulldown("account")}>{$account.username ? `@${$account.username}` : "account"}</button>
<button class="menuBtn" on:click={() => pulldown.openPulldown("account")}>{$account?.username ? `@${$account.username}` : "account"}</button>
<button class="menuBtn" on:click={() => pulldown.openPulldown("help")}>help</button>
<div /> <!-- not sure what's offcenter but something is

View file

@ -1,9 +1,10 @@
<script>
<script lang="ts">
import { _void } from "./transition/_void.js"
import { padding_scaleY } from "./transition/padding_scaleY.js"
import { fade } from "svelte/transition"
import { circIn, circOut } from "svelte/easing"
import { serverStats, refresh_stats, account } from "./stores.mjs"
import { serverStats, refresh_stats, account } from "./stores.js"
import bytes from "bytes"
import AttachmentZone from "./uploader/AttachmentZone.svelte"
@ -13,56 +14,48 @@
// uploads
interface Upload {
file: string | File
params: {
uploadId?: string
}
uploadStatus: {
fileId?: string,
error?: string,
}
maximized?: boolean,
viewingUrl?: boolean
}
let attachmentZone
let uploads = {}
let uploads: Record<string, Upload> = {}
let uploadInProgress = false
let notificationPermission =
globalThis?.Notification?.permission ?? "denied"
let handle_file_upload = (ev) => {
if (ev.detail.type == "clone") {
uploads[Math.random().toString().slice(2)] = {
type: "clone",
name: ev.detail.url,
url: ev.detail.url,
let handle_file_upload = (file: Event & { detail: File|string }) => {
params: {
uploadId: "",
},
uploads[Math.random().toString().slice(2)] = {
file: file.detail,
uploadStatus: {
fileId: null,
error: null,
},
}
params: {
uploadId: "",
},
uploads = uploads
} else if (ev.detail.type == "upload") {
ev.detail.files.forEach((v, x) => {
uploads[Math.random().toString().slice(2)] = {
type: "upload",
name: v.name,
file: v,
params: {
uploadId: "",
},
uploadStatus: {
fileId: null,
error: null,
},
}
})
uploads = uploads
uploadStatus: {}
}
uploads = uploads
}
let handle_fetch_promise = (x, prom) => {
let handle_fetch_promise = (x: string, prom: Promise<Response>) => {
return prom
.then(async (res) => {
let txt = await res.text()
if (txt.startsWith("[err]")) uploads[x].uploadStatus.error = txt
if (!res.ok) uploads[x].uploadStatus.error = txt
else {
uploads[x].uploadStatus.fileId = txt
try {
@ -80,8 +73,8 @@
],
}).addEventListener(
"notificationclick",
({ action }) => {
if (action === "open") {
(event) => {
if ("action" in event && event.action === "open") {
open(
"/download/" +
uploads[x].uploadStatus.fileId
@ -112,35 +105,14 @@
// quick patch-in to allow for a switch to have everything upload sequentially
// switch will have a proper menu option later, for now i'm lazy so it's just gonna be a Secret
let hdl = () => {
switch (v.type) {
case "upload":
let fd = new FormData()
fd.append("file", v.file)
let fd = new FormData()
if (v.params.uploadId) fd.append("uploadId", v.params.uploadId)
fd.append("file", v.file)
return handle_fetch_promise(
x,
fetch("/upload", {
headers: {
"monofile-params": JSON.stringify(v.params),
},
method: "POST",
body: fd,
})
)
break
case "clone":
return handle_fetch_promise(
x,
fetch("/clone", {
method: "POST",
body: JSON.stringify({
url: v.url,
...v.params,
}),
})
)
break
}
return handle_fetch_promise(x,fetch("/api/v1/file",{
method: "PUT",
body: fd
}))
}
if (sequential) await hdl()
@ -150,10 +122,10 @@
// animation
function fileTransition(node) {
function fileTransition(node: HTMLElement) {
return {
duration: 300,
css: (t) => {
css: (t: number) => {
let eased = circOut(t)
return `
@ -195,7 +167,7 @@
</h1>
<p style:color="#999999">
<span class="number"
>{$serverStats.version ? `v${$serverStats.version}` : "•••"}</span
>{$serverStats?.version ? `v${$serverStats?.version}` : "•••"}</span
>&nbsp;&nbsp;&nbsp;&nbsp;Discord based file sharing
</p>
@ -215,14 +187,9 @@
: ""}
>
<h2>
{upload[1].name}
{typeof upload[1].file == "string" ? upload[1].file : upload[1].file.name}
<span style:color="#999999" style:font-weight="400"
>{upload[1].type}{@html upload[1].type == "upload"
? `&nbsp;(${Math.round(
upload[1].file.size / 1048576
)}MiB)`
: ""}</span
>
>{@html typeof upload[1].file == "string" ? "clone" : `upload&nbsp;(${bytes(upload[1].file.size)})`}</span>
</h2>
{#if upload[1].maximized && !uploadInProgress}
@ -341,7 +308,7 @@
{#if uploadInProgress == false}
<!-- if required for upload, check if logged in -->
{#if ($serverStats.accounts || {}).requiredForUpload ? !!$account.username : true}
{#if $serverStats?.accounts?.requiredForUpload ? !!$account?.username : true}
<AttachmentZone
bind:this={attachmentZone}
on:addFiles={handle_file_upload}
@ -374,14 +341,15 @@
<p style:color="#999999" style:text-align="center">
Hosting <span class="number" style:font-weight="600"
>{$serverStats.files || "•••"}</span
>{$serverStats?.files ?? "•••"}</span
>
files — Maximum filesize is
<span class="number" style:font-weight="600"
>{(($serverStats.maxDiscordFileSize || 0) *
($serverStats.maxDiscordFiles || 0)) /
1048576 || "•••"}MiB</span
>
<span class="number" style:font-weight="600">
{
$serverStats?.maxDiscordFiles
? bytes($serverStats.maxDiscordFileSize * $serverStats.maxDiscordFiles)
: "•••"
}</span>
<br />
</p>
<p style:color="#999999" style:text-align="center" style:font-size="12px">

View file

@ -1,28 +1,30 @@
<script>
<script lang="ts">
import { fade, slide } from "svelte/transition";
interface BaseModalOption {
name:string,
icon:string,
id: string | number | symbol | boolean
}
let activeModal;
let modalResults;
type ModalOption = BaseModalOption & {inputSettings: {password?: boolean}, id: any} | BaseModalOption & { description: string }
/**
*
* @param mdl {name:string,icon:string,description:string,id:string}[]
* @returns Promise
*/
export function picker(title,mdl) {
type ModalOptions = ModalOption[]
type OptionPickerReturns = {selected: any} & Record<any,any> | null
let activeModal: {resolve: (val: OptionPickerReturns) => void, title: string, modal: ModalOptions } | undefined;
let modalResults: Record<string | number | symbol, string> = {};
export function picker(title: string,mdl: ModalOptions): Promise<OptionPickerReturns> {
if (activeModal) forceCancel()
return new Promise((resolve,reject) => {
return new Promise<OptionPickerReturns>((resolve,reject) => {
activeModal = {
resolve,
title,
modal:mdl
}
modalResults = {
}
modalResults = {}
})
}
@ -30,7 +32,7 @@
if (activeModal && activeModal.resolve) {
activeModal.resolve(null)
}
activeModal = null
activeModal = undefined
}
</script>
@ -46,9 +48,9 @@
</div>
{#each activeModal.modal as option (option.id)}
{#if option.inputSettings}
{#if "inputSettings" in option}
<div class="inp">
<img src={option.icon} alt={option.id}>
<img src={option.icon} alt={option.id.toString()}>
<!-- i have to do this stupidness because of svelte but -->
<!-- its reason for blocking this is pretty good sooooo -->
@ -60,8 +62,8 @@
{/if}
</div>
{:else}
<button on:click={() => {activeModal.resolve({...modalResults,selected:option.id});activeModal=null;modalResults=null;}}>
<img src={option.icon} alt={option.id}>
<button on:click={() => {activeModal?.resolve({...modalResults,selected:option.id});activeModal=undefined;modalResults={};}}>
<img src={option.icon} alt={option.id.toString()}>
<p>{option.name}<span><br />{option.description}</span></p>
</button>
{/if}

View file

@ -1,7 +1,8 @@
import { fetchAccountData, account, refreshNeeded } from "../stores.mjs"
import { fetchAccountData, account, refreshNeeded } from "../stores"
import { get } from "svelte/store";
import type OptionPicker from "./OptionPicker.svelte";
export function deleteAccount(optPicker) {
export function deleteAccount(optPicker: OptionPicker) {
optPicker.picker("What should we do with your files?",[
{
name: "Delete my files",
@ -56,7 +57,7 @@ export function deleteAccount(optPicker) {
})
}
export function userChange(optPicker) {
export function userChange(optPicker: OptionPicker) {
optPicker.picker("Change username",[
{
name: "New username",
@ -86,7 +87,7 @@ export function userChange(optPicker) {
})
}
export function forgotPassword(optPicker) {
export function forgotPassword(optPicker: OptionPicker) {
optPicker.picker("Forgot your password?",[
{
name: "Username",
@ -115,7 +116,7 @@ export function forgotPassword(optPicker) {
})
}
export function emailPotentialRemove(optPicker) {
export function emailPotentialRemove(optPicker: OptionPicker) {
optPicker.picker("What would you like to do?",[
{
name: "Set a new email",
@ -148,7 +149,7 @@ export function emailPotentialRemove(optPicker) {
})
}
export function emailChange(optPicker) {
export function emailChange(optPicker: OptionPicker) {
optPicker.picker("Change email",[
{
name: "New email",
@ -177,7 +178,7 @@ export function emailChange(optPicker) {
})
}
export function pwdChng(optPicker) {
export function pwdChng(optPicker: OptionPicker) {
optPicker.picker("Change password",[
{
name: "New password",
@ -209,7 +210,7 @@ export function pwdChng(optPicker) {
})
}
export function customcss(optPicker) {
export function customcss(optPicker: OptionPicker) {
optPicker.picker("Set custom CSS",[
{
name: "Enter a file ID",
@ -225,23 +226,32 @@ export function customcss(optPicker) {
}
]).then((exp) => {
if (exp && exp.selected) {
fetch(`/auth/customcss`,{method:"POST", body:JSON.stringify({
fileId:exp.fileid
})}).then((response) => {
fetch(`/api/v1/account/customization/css`, {
method: "PUT",
body: JSON.stringify({
fileId: exp.fileid,
}),
}).then((response) => {
if (response.status != 200) {
optPicker.picker(`${response.status} ${response.headers.get("x-backup-status-message") || response.statusText || ""}`,[])
optPicker.picker(
`${response.status} ${
response.headers.get("x-backup-status-message") ||
response.statusText ||
""
}`,
[]
)
}
fetchAccountData()
refreshNeeded.set(true);
refreshNeeded.set(true)
})
}
})
}
export function embedColor(optPicker) {
export function embedColor(optPicker: OptionPicker) {
optPicker.picker("Set embed color",[
{
name: "FFFFFF",
@ -257,12 +267,21 @@ export function embedColor(optPicker) {
}
]).then((exp) => {
if (exp && exp.selected) {
fetch(`/auth/embedcolor`,{method:"POST", body:JSON.stringify({
color:exp.color
})}).then((response) => {
fetch(`/api/v1/account/customization/embed/color`, {
method: "POST",
body: JSON.stringify({
color: exp.color,
}),
}).then((response) => {
if (response.status != 200) {
optPicker.picker(`${response.status} ${response.headers.get("x-backup-status-message") || response.statusText || ""}`,[])
optPicker.picker(
`${response.status} ${
response.headers.get("x-backup-status-message") ||
response.statusText ||
""
}`,
[]
)
}
fetchAccountData()
@ -272,7 +291,7 @@ export function embedColor(optPicker) {
}
export function embedSize(optPicker) {
export function embedSize(optPicker: OptionPicker) {
optPicker.picker("Set embed image size",[
{
name: "Large",
@ -288,16 +307,25 @@ export function embedSize(optPicker) {
}
]).then((exp) => {
if (exp && exp.selected !== null) {
fetch(`/auth/embedsize`,{method:"POST", body:JSON.stringify({
largeImage:exp.selected
})}).then((response) => {
fetch(`/api/v1/account/customization/embed/size`, {
method: "POST",
body: JSON.stringify({
largeImage: exp.selected,
}),
}).then((response) => {
if (response.status != 200) {
optPicker.picker(`${response.status} ${response.headers.get("x-backup-status-message") || response.statusText || ""}`,[])
optPicker.picker(
`${response.status} ${
response.headers.get("x-backup-status-message") ||
response.statusText ||
""
}`,
[]
)
}
fetchAccountData()
})
}
})
}
}

View file

@ -1,7 +1,8 @@
import { fetchAccountData, fetchFilePointers, account } from "../stores.mjs"
import { fetchAccountData, fetchFilePointers, account } from "../stores"
import { get } from "svelte/store";
import type OptionPicker from "./OptionPicker.svelte";
export function pwdReset(optPicker) {
export function pwdReset(optPicker: OptionPicker) {
optPicker.picker("Reset password",[
{
name: "Target user",
@ -39,7 +40,7 @@ export function pwdReset(optPicker) {
})
}
export function chgOwner(optPicker) {
export function chgOwner(optPicker: OptionPicker) {
optPicker.picker("Transfer file ownership",[
{
name: "File ID",
@ -75,7 +76,7 @@ export function chgOwner(optPicker) {
})
}
export function chgId(optPicker) {
export function chgId(optPicker: OptionPicker) {
optPicker.picker("Change file ID",[
{
name: "Target file",
@ -111,7 +112,7 @@ export function chgId(optPicker) {
})
}
export function delFile(optPicker) {
export function delFile(optPicker: OptionPicker) {
optPicker.picker("Delete file",[
{
name: "File ID",
@ -140,7 +141,7 @@ export function delFile(optPicker) {
})
}
export function elevateUser(optPicker) {
export function elevateUser(optPicker: OptionPicker) {
optPicker.picker("Elevate user",[
{
name: "Username",
@ -171,7 +172,7 @@ export function elevateUser(optPicker) {
// im really lazy so i just stole this from account.js
export function deleteAccount(optPicker) {
export function deleteAccount(optPicker: OptionPicker) {
optPicker.picker("What should we do with the target account's files?",[
{
name: "Delete files",

View file

@ -1,5 +1,7 @@
import { fetchAccountData, fetchFilePointers, account } from "../stores.mjs"
import { fetchAccountData, fetchFilePointers, account } from "../stores"
import { get } from "svelte/store";
import type OptionPicker from "./OptionPicker.svelte"
import type { FilePointer } from "../../../server/lib/files";
export let options = {
FV: [
@ -51,7 +53,7 @@ export let options = {
]
}
export function dfv(optPicker) {
export function dfv(optPicker: OptionPicker) {
optPicker.picker("Default file visibility",options.FV).then((exp) => {
if (exp && exp.selected) {
fetch(`/auth/dfv`,{method:"POST", body:JSON.stringify({
@ -68,21 +70,21 @@ export function dfv(optPicker) {
})
}
export function update_all_files(optPicker) {
export function update_all_files(optPicker: OptionPicker) {
optPicker.picker("You sure?",[
{
name: "Yeah",
icon: "/static/assets/icons/update.svg",
description: `This will make all of your files ${get(account).defaultFileVisibility || "public"}`,
description: `This will make all of your files ${get(account)?.defaultFileVisibility || "public"}`,
id: true
}
]).then((exp) => {
if (exp && exp.selected) {
fetch(`/files/manage`,{method:"POST", body:JSON.stringify({
target:get(account).files,
target:get(account)?.files,
action: "changeFileVisibility",
value: get(account).defaultFileVisibility
value: get(account)?.defaultFileVisibility
})}).then((response) => {
if (response.status != 200) {
@ -95,7 +97,7 @@ export function update_all_files(optPicker) {
})
}
export function fileOptions(optPicker,file) {
export function fileOptions(optPicker: OptionPicker, file: FilePointer & {id:string}) {
optPicker.picker(file.filename,[
{
name: file.tag ? "Remove tag" : "Tag file",

View file

@ -1,26 +1,26 @@
<script>
<script lang="ts">
import Pulldown from "./Pulldown.svelte"
import { padding_scaleY } from "../transition/padding_scaleY"
import { circIn,circOut } from "svelte/easing"
import { account, fetchAccountData, serverStats, refreshNeeded } from "../stores.mjs";
import { account, fetchAccountData, serverStats, refreshNeeded } from "../stores";
import { fade } from "svelte/transition";
import OptionPicker from "../prompts/OptionPicker.svelte";
import * as accOpts from "../prompts/account";
import * as uplOpts from "../prompts/uploads";
import * as admOpts from "../prompts/admin";
let targetAction
let inProgress
let authError
let targetAction: "login"|"create"
let inProgress: boolean
let authError:{status:number,message:string}|undefined
let pwErr
let pwErr: HTMLDivElement
let optPicker;
let optPicker: OptionPicker;
// lazy
let username
let password
let username: string
let password: string
let execute = () => {
if (inProgress) return
@ -43,7 +43,7 @@
}
})
} else {
authError = null, username = "", password = "";
authError = undefined, username = "", password = "";
fetchAccountData();
}
}).catch(() => {})
@ -66,55 +66,7 @@
<Pulldown name="accounts">
<OptionPicker bind:this={optPicker} />
{#if Object.keys($account).length == 0}
<div class="notLoggedIn" transition:fade={{duration:200}}>
<div class="container_div">
<h1>monofile <span style:color="#999999">accounts</span></h1>
<p class="flavor">Gain control of your uploads.</p>
{#if targetAction}
<div class="fields" out:padding_scaleY|local={{easingFunc:circIn}} in:padding_scaleY|local>
{#if !$serverStats.accounts.registrationEnabled && targetAction == "create"}
<div class="pwError">
<div style:background-color="#554C33">
<p>Account registration has been disabled by this instance's owner</p>
</div>
</div>
{/if}
{#if authError}
<div class="pwError" out:padding_scaleY|local={{easingFunc:circIn}} in:padding_scaleY|local>
<div bind:this={pwErr}>
<p><strong>{authError.status}</strong> {authError.message}</p>
</div>
</div>
{/if}
<input placeholder="username" type="text" bind:value={username}>
<input placeholder="password" type="password" bind:value={password}>
<button on:click={execute}>{ inProgress ? "• • •" : (targetAction=="login" ? "Log in" : "Create account") }</button>
{#if targetAction == "login"}
<button class="flavor" on:click={() => accOpts.forgotPassword(optPicker)}>I forgot my password</button>
{/if}
</div>
{:else}
<div class="lgBtnContainer" out:padding_scaleY|local={{easingFunc:circIn}} in:padding_scaleY|local>
<button on:click={() => targetAction="login"}>Log in</button>
<button on:click={() => targetAction="create"}>Sign up</button>
</div>
{/if}
</div>
</div>
{:else}
{#if $account}
<div class="loggedIn" transition:fade={{duration:200}}>
<h1>
Hey there, <span class="monospace">@{$account.username}</span>
@ -131,7 +83,7 @@
<p>Change username</p>
</button>
<button on:click={() => ($account.email ? accOpts.emailPotentialRemove : accOpts.emailChange)(optPicker)}>
<button on:click={() => ($account?.email ? accOpts.emailPotentialRemove : accOpts.emailChange)(optPicker)}>
<img src="/static/assets/icons/mail.svg" alt="change email">
<p>Change email{#if $account.email}<span class="monospaceText"><br />{$account.email}</span>{/if}</p>
</button>
@ -182,7 +134,7 @@
</button>
{#if $refreshNeeded}
<button on:click={() => window.location.reload(true)} transition:fade={{duration: 200}}>
<button on:click={() => window.location.reload()} transition:fade={{duration: 200}}>
<img src="/static/assets/icons/refresh.svg" alt="refresh">
<p>Refresh<span><br />Changes were made which require a refresh</span></p>
</button>
@ -194,12 +146,12 @@
<button on:click={() => fetch(`/auth/logout_sessions`,{method:"POST"}).then(() => fetchAccountData())}>
<img src="/static/assets/icons/logout_all.svg" alt="logout_all">
<p>Log out all sessions<span><br />{$account.sessionCount} session(s) active</span></p>
<p>Log out all sessions<span><br />{$account?.sessionCount} session(s) active</span></p>
</button>
<button on:click={() => fetch(`/auth/logout`,{method:"POST"}).then(() => fetchAccountData())}>
<img src="/static/assets/icons/logout.svg" alt="logout">
<p>Log out<span><br />Session expires {new Date($account.sessionExpires).toLocaleDateString()}</span></p>
<p>Log out<span><br />Session expires {new Date($account?.sessionExpires).toLocaleDateString()}</span></p>
</button>
{#if $account.admin}
@ -242,6 +194,50 @@
<p style="font-size:12px;color:#AAAAAA;text-align:center;" class="monospace"><br />{$account.id}</p>
</div>
</div>
{:else}
<div class="notLoggedIn" transition:fade={{duration:200}}>
<div class="container_div">
<h1>monofile <span style:color="#999999">accounts</span></h1>
<p class="flavor">Gain control of your uploads.</p>
{#if targetAction}
<div class="fields" out:padding_scaleY|local={{easingFunc:circIn}} in:padding_scaleY|local>
{#if !$serverStats?.accounts.registrationEnabled && targetAction == "create"}
<div class="pwError">
<div style:background-color="#554C33">
<p>Account registration has been disabled by this instance's owner</p>
</div>
</div>
{/if}
{#if authError}
<div class="pwError" out:padding_scaleY|local={{easingFunc:circIn}} in:padding_scaleY|local>
<div bind:this={pwErr}>
<p><strong>{authError.status}</strong> {authError.message}</p>
</div>
</div>
{/if}
<input placeholder="username" type="text" bind:value={username}>
<input placeholder="password" type="password" bind:value={password}>
<button on:click={execute}>{@html inProgress ? "<span class=loader><i>•</i> <i>•</i> <i>•</i></span>" : (targetAction=="login" ? "Log in" : "Create account") }</button>
{#if targetAction == "login"}
<button class="flavor" on:click={() => accOpts.forgotPassword(optPicker)}>I forgot my password</button>
{/if}
</div>
{:else}
<div class="lgBtnContainer" out:padding_scaleY|local={{easingFunc:circIn}} in:padding_scaleY|local>
<button on:click={() => targetAction="login"}>Log in</button>
<button on:click={() => targetAction="create"}>Sign up</button>
</div>
{/if}
</div>
</div>
{/if}
</Pulldown>

View file

@ -1,13 +1,13 @@
<script>
<script lang="ts">
import Pulldown from "./Pulldown.svelte";
import { account, fetchFilePointers, files, pulldownManager } from "../stores.mjs";
import { account, fetchFilePointers, files, pulldownManager } from "../stores.js";
import { fade } from "svelte/transition";
import { flip } from "svelte/animate";
import { fileOptions } from "../prompts/uploads";
import OptionPicker from "../prompts/OptionPicker.svelte";
let picker;
let picker: OptionPicker;
let query = "";
fetchFilePointers();
@ -17,48 +17,47 @@
<OptionPicker bind:this={picker} />
{#if !$account.username}
{#if $account?.username}<div class="loggedIn">
<input type="text" placeholder={`Search ${$files.length} file(s)`} class="searchBar" bind:value={query}>
<div class="fileList">
<!-- Probably wildly inefficient but who cares, I just wanna get this over with -->
{#each $files.filter(f => f&&(f.filename.toLowerCase().includes(query.toLowerCase()) || f.id.toLowerCase().includes(query.toLowerCase()) || f.tag?.includes(query.toLowerCase()))) as file (file.id)}
<div class="flFile" transition:fade={{duration:200}} animate:flip={{duration:200}}>
<button class="hitbox" on:click={() => window.open(`/download/${file.id}`)}></button> <!-- this is bad, but I'm lazy -->
<div class="flexCont">
<div class="fileInfo">
<h2>{file.filename}</h2>
<p class="detail">
<img src="/static/assets/icons/{file.visibility || "public"}.svg" alt={file.visibility||"public"} />&nbsp;
<span class="number">{file.id}</span>&nbsp;&nbsp;&nbsp;&nbsp;<span class="cd">{file.mime.split(";")[0]}</span>
{#if file.reserved}
<br />
<img src="/static/assets/icons/update.svg" alt="uploading"/>&nbsp;
Uploading...
{/if}
{#if file.tag}
<br />
<img src="/static/assets/icons/tag.svg" alt="tag"/>&nbsp;
<span class="cd">{file.tag}</span>
{/if}
</p>
</div>
<button class="more" on:click={() => fileOptions(picker, file)}>
<img src="/static/assets/icons/more.svg" alt="more" />
</button>
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="notLoggedIn">
<div style:height="10px" />
<p class="flavor">Log in to view uploads</p>
<button on:click={$pulldownManager.openPulldown("account")}>OK</button>
<div style:height="14px" />
</div>
{:else}
<div class="loggedIn">
<input type="text" placeholder={`Search ${$files.length} file(s)`} class="searchBar" bind:value={query}>
<div class="fileList">
<!-- Probably wildly inefficient but who cares, I just wanna get this over with -->
{#each $files.filter(f => f&&(f.filename.toLowerCase().includes(query.toLowerCase()) || f.id.toLowerCase().includes(query.toLowerCase()) || f.tag?.includes(query.toLowerCase()))) as file (file.id)}
<div class="flFile" transition:fade={{duration:200}} animate:flip={{duration:200}}>
<button class="hitbox" on:click={window.open(`/download/${file.id}`)}></button> <!-- this is bad, but I'm lazy -->
<div class="flexCont">
<div class="fileInfo">
<h2>{file.filename}</h2>
<p class="detail">
<img src="/static/assets/icons/{file.visibility || "public"}.svg" alt={file.visibility||"public"} />&nbsp;
<span class="number">{file.id}</span>&nbsp;&nbsp;&nbsp;&nbsp;<span class="cd">{file.mime.split(";")[0]}</span>
{#if file.reserved}
<br />
<img src="/static/assets/icons/update.svg" alt="uploading"/>&nbsp;
Uploading...
{/if}
{#if file.tag}
<br />
<img src="/static/assets/icons/tag.svg" alt="tag"/>&nbsp;
<span class="cd">{file.tag}</span>
{/if}
</p>
</div>
<button class="more" on:click={fileOptions(picker, file)}>
<img src="/static/assets/icons/more.svg" alt="more" />
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
</Pulldown>

View file

@ -1,13 +1,13 @@
<script>
<script lang=ts>
import { fade } from "svelte/transition";
export let name;
export let name: string;
</script>
<div
class="pulldown_display"
name={name}
data-name={name}
transition:fade={{duration:150}}
>
<slot />

View file

@ -1,35 +0,0 @@
import { writable } from "svelte/store"
export let refreshNeeded = writable(false)
export let pulldownManager = writable(0)
export let account = writable({})
export let serverStats = writable({})
export let files = writable([])
export let fetchAccountData = function() {
fetch("/auth/me").then(async (response) => {
if (response.status == 200) {
account.set(await response.json())
} else {
account.set({})
}
}).catch((err) => { console.error(err) })
}
export let fetchFilePointers = function() {
fetch("/files/list", { cache: "no-cache" }).then(async (response) => {
if (response.status == 200) {
files.set(await response.json())
} else {
files.set([])
}
}).catch((err) => { console.error(err) })
}
export let refresh_stats = () => {
fetch("/server").then(async (data) => {
serverStats.set(await data.json())
}).catch((err) => { console.error(err) })
}
fetchAccountData()

54
src/svelte/elem/stores.ts Normal file
View file

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

View file

@ -1,20 +0,0 @@
import { circIn, circOut } from "svelte/easing"
export function _void(node, { duration, easingFunc, op, prop, rTarg }) {
let rect = node.getBoundingClientRect()
return {
duration: duration||300,
css: t => {
let eased = (easingFunc || circIn)(t)
return `
white-space: nowrap;
${prop||"height"}: ${(eased)*(rect[rTarg||prop||"height"])}px;
padding: 0px;
opacity:${eased};
overflow: clip;
`
}
}
}

View file

@ -0,0 +1,23 @@
import { circIn, circOut } from "svelte/easing"
export function _void(
node: HTMLElement,
options?: { duration?:number, easingFunc?: (a:number)=>number, prop?:string, rTarg?: "height"|"width"}
) {
const { duration = 300, easingFunc = circIn, prop, rTarg } = options ?? {}
let rect = node.getBoundingClientRect()
return {
duration,
css: (t: number) => {
let eased = easingFunc(t)
return `
white-space: nowrap;
${prop||"height"}: ${(eased)*(rect[rTarg || (prop && prop in rect) ? prop as keyof Omit<DOMRect, "toJSON"> : "height"])}px;
padding: 0px;
opacity:${eased};
overflow: clip;
`
}
}
}

View file

@ -1,18 +0,0 @@
import { circIn, circOut } from "svelte/easing"
export function padding_scaleY(node, { duration, easingFunc, padY, padX, op }) {
let rect = node.getBoundingClientRect()
return {
duration: duration||300,
css: t => {
let eased = (easingFunc || circOut)(t)
return `
height: ${eased*(rect.height-(padY||0))}px;
${padX&&padY ? `padding: ${(eased)*(padY)}px ${(padX)}px;` : ""}
${op ? `opacity: ${eased};` : ""}
`
}
}
}

View file

@ -0,0 +1,21 @@
import { circIn, circOut } from "svelte/easing"
function padding_scaleY(node: HTMLElement, options?: { duration?: number, easingFunc?: (a: number) => number, padY?: number, padX?: number, op?: boolean }) {
const { duration = 300, easingFunc = circOut, padY, padX, op } = options ?? {}
let rect = node.getBoundingClientRect()
return {
duration,
css: (t:number) => {
let eased = easingFunc(t)
return `
height: ${eased*(rect.height-(padY||0))}px;
${padX&&padY ? `padding: ${(eased)*(padY)}px ${(padX)}px;` : ""}
${op ? `opacity: ${eased};` : ""}
`
}
}
}
export {padding_scaleY}

View file

@ -1,56 +1,35 @@
<script>
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { circIn, circOut } from "svelte/easing"
import { fade } from "svelte/transition";
import { circOut } from "svelte/easing"
import { _void } from "../transition/_void"
let uploadTypes = {
files: 1,
clone: 2
enum UploadTypes {
None,
Files,
Clone
}
let uploadType = undefined
let uploadType: UploadTypes = UploadTypes.None
let dispatch = createEventDispatcher();
// file upload
/**
* @type HTMLInputElement
*/
let fileUpload;
$: {
if (fileUpload) {
fileUpload.addEventListener("change",() => {
dispatch("addFiles",{
type: "upload",
files: Array.from(fileUpload.files)
})
uploadType = undefined
})
}
let files: FileList | undefined
$: if (files) {
[...files].forEach(file=>dispatch("addFiles", file))
uploadType = UploadTypes.None
}
// file clone
/**
* @type HTMLButtonElement
*/
let cloneButton;
/**
* @type HTMLInputElement
*/
let cloneUrlTextbox;
let cloneUrlTextbox: HTMLInputElement;
let cloneForm: HTMLFormElement;
$: {
if (cloneButton && cloneUrlTextbox) {
cloneButton.addEventListener("click",() => {
if (cloneForm && cloneUrlTextbox) {
cloneForm.addEventListener("submit",(e) => {
e.preventDefault()
if (cloneUrlTextbox.value) {
dispatch("addFiles",{
type: "clone",
url: cloneUrlTextbox.value
})
uploadType = undefined;
dispatch("addFiles",cloneUrlTextbox.value)
uploadType = UploadTypes.None;
} else {
cloneUrlTextbox.animate([
{"transform":"translateX(0px)"},
@ -68,26 +47,26 @@
<div id="add_new_files" transition:_void={{duration:200}}>
<p>
+<span class="_add_files_txt">add files</span>
+<span class="add_files_txt">add files</span>
</p>
{#if !uploadType}
{#if uploadType == UploadTypes.None}
<div id="file_add_btns" out:_void in:_void={{easingFunc:circOut}}>
<button on:click={() => uploadType = uploadTypes.files} >upload files...</button>
<button on:click={() => uploadType = uploadTypes.clone} >clone url...</button>
<button on:click={() => uploadType = UploadTypes.Files} >upload files...</button>
<button on:click={() => uploadType = UploadTypes.Clone} >clone url...</button>
</div>
{:else}
{#if uploadType == uploadTypes.files}
{#if uploadType == UploadTypes.Files}
<div id="file_add_btns" out:_void in:_void={{easingFunc:circOut}}>
<div class="fileUpload">
<p>click/tap to browse<br/>or drag files into this box</p>
<input type="file" multiple bind:this={fileUpload}>
<input type="file" multiple bind:files={files}>
</div>
</div>
{:else if uploadType == uploadTypes.clone}
<div id="file_add_btns" out:_void in:_void={{easingFunc:circOut}}>
{:else if uploadType == UploadTypes.Clone}
<form id="file_add_btns" out:_void in:_void={{easingFunc:circOut}} bind:this={cloneForm}>
<input placeholder="url" type="text" bind:this={cloneUrlTextbox}>
<button style:flex-basis="30%" bind:this={cloneButton}>add file</button>
</div>
<input type="submit" value="add file" style:flex-basis="30%">
</form>
{/if}
{/if}
</div>

1
src/svelte/global.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="svelte" />

5
src/svelte/index.ts Normal file
View file

@ -0,0 +1,5 @@
import App from "./App.svelte"
new App({
target: document.body
})

18
src/svelte/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["**/*"],
"compilerOptions": {
"target": "ESNext",
"outDir": "../../dist/static/vite",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleResolution": "bundler"
},
"references": [
{ "path": "../../tsconfig.json" }
]
}

View file

@ -1,104 +1,10 @@
{
"include":["src/server/**/*"],
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./src/", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./out/server", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
"compilerOptions": {
"rootDir": ".",
"outDir": ".",
"resolveJsonModule": true,
"composite": true,
"skipLibCheck": true
},
"files": ["package.json"]
}

21
vite.config.ts Normal file
View file

@ -0,0 +1,21 @@
import { defineConfig } from "vite"
import { svelte } from "@sveltejs/vite-plugin-svelte"
import autoPreprocess from "svelte-preprocess"
import { resolve } from "path"
export default defineConfig({
root: "./src",
build: {
outDir: "../dist",
assetsDir: "static/vite",
rollupOptions: {
input: {
main: resolve(__dirname, "src/index.html"),
download: resolve(__dirname, "src/download.html"),
error: resolve(__dirname, "src/error.html"),
},
},
},
plugins: [svelte({
preprocess: autoPreprocess()
})],
})