mirror of
https://github.com/mollersuite/monofile.git
synced 2024-11-23 06:06:27 -08:00
Merge 32a297d2ef
into b59d1b24ff
This commit is contained in:
commit
40e7350390
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
.vscode
|
||||||
|
.gitignore
|
||||||
|
.prettierrc
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.data
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
tsconfig.tsbuildinfo
|
23
.env.example
Normal file
23
.env.example
Normal 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=
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -2,3 +2,5 @@ node_modules
|
||||||
.env
|
.env
|
||||||
.data
|
.data
|
||||||
out
|
out
|
||||||
|
dist
|
||||||
|
tsconfig.tsbuildinfo
|
6
.prettierrc
Normal file
6
.prettierrc
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"tabWidth": 4
|
||||||
|
}
|
27
Dockerfile
Normal file
27
Dockerfile
Normal 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" ]
|
|
@ -26,7 +26,6 @@ Invite your bot to a server, and create a new `config.json` in the project root:
|
||||||
{
|
{
|
||||||
"maxDiscordFiles": 20,
|
"maxDiscordFiles": 20,
|
||||||
"maxDiscordFileSize": 26214400,
|
"maxDiscordFileSize": 26214400,
|
||||||
"targetGuild": "1024080490677936248",
|
|
||||||
"targetChannel": "1024080525993971913",
|
"targetChannel": "1024080525993971913",
|
||||||
"requestTimeout":120000,
|
"requestTimeout":120000,
|
||||||
"maxUploadIdLength":30,
|
"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).
|
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).
|
Icons under `/assets/icons` were created by Microsoft, and as such are licensed under [different terms](./assets/icons/README.md) (MIT).
|
||||||
|
|
||||||
|
|
|
@ -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>
|
<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
BIN
assets/moller.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 211 KiB |
14
config.json
14
config.json
|
@ -1,11 +1,10 @@
|
||||||
{
|
{
|
||||||
"maxDiscordFiles": 20,
|
"maxDiscordFiles": 1000,
|
||||||
"maxDiscordFileSize": 26214400,
|
"maxDiscordFileSize": 10485760,
|
||||||
"targetGuild": "1024080490677936248",
|
"targetGuild": "906767804575928390",
|
||||||
"targetChannel": "1024080525993971913",
|
"targetChannel": "1024080525993971913",
|
||||||
"requestTimeout":120000,
|
"requestTimeout": 3600000,
|
||||||
"maxUploadIdLength":30,
|
"maxUploadIdLength": 30,
|
||||||
|
|
||||||
"accounts": {
|
"accounts": {
|
||||||
"registrationEnabled": true,
|
"registrationEnabled": true,
|
||||||
"requiredForUpload": false
|
"requiredForUpload": false
|
||||||
|
@ -21,7 +20,6 @@
|
||||||
"from": "mono@fyle.uk"
|
"from": "mono@fyle.uk"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"trustProxy": true,
|
"trustProxy": true,
|
||||||
"forceSSL": true
|
"forceSSL": false
|
||||||
}
|
}
|
10
docker-compose.dev.yml
Normal file
10
docker-compose.dev.yml
Normal 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"
|
2861
package-lock.json
generated
2861
package-lock.json
generated
File diff suppressed because it is too large
Load diff
44
package.json
44
package.json
|
@ -3,40 +3,52 @@
|
||||||
"version": "2.0.0-dev",
|
"version": "2.0.0-dev",
|
||||||
"description": "Discord-based file sharing",
|
"description": "Discord-based file sharing",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./out/server/index.js",
|
"start": "node ./out/server/index.js",
|
||||||
"build": "tsc\nsass src/style:out/style\nrollup -c",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"dev": "vite",
|
||||||
|
"build": "tsc --build src/server && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Etcetera (https://cetera.uk)",
|
"author": "Etcetera (https://cetera.uk)",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=v16.11"
|
"node": ">=v21"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "^1.19.2",
|
"@hono/node-server": "^1.8.2",
|
||||||
"@types/express": "^4.17.14",
|
|
||||||
"@types/multer": "^1.4.7",
|
|
||||||
"@types/nodemailer": "^6.4.8",
|
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"body-parser": "^1.20.0",
|
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"cookie-parser": "^1.4.6",
|
"commander": "^11.1.0",
|
||||||
"discord.js": "^14.7.1",
|
|
||||||
"dotenv": "^16.0.2",
|
"dotenv": "^16.0.2",
|
||||||
"express": "^4.18.1",
|
"formidable": "^3.5.1",
|
||||||
|
"hono": "^4.0.10",
|
||||||
|
"jose": "^5.2.4",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
"nodemailer": "^6.9.3",
|
"nodemailer": "^6.9.3",
|
||||||
"typescript": "^4.8.3"
|
"range-parser": "^1.2.1",
|
||||||
|
"zod": "^3.23.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
"@sveltejs/vite-plugin-svelte": "^2.4.6",
|
||||||
|
"@tsconfig/svelte": "^4.0.1",
|
||||||
|
"@types/body-parser": "^1.19.2",
|
||||||
"@types/bytes": "^3.1.1",
|
"@types/bytes": "^3.1.1",
|
||||||
"@types/cookie-parser": "^1.4.3",
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"rollup": "^3.11.0",
|
"@types/express": "^4.17.14",
|
||||||
"rollup-plugin-svelte": "^7.1.0",
|
"@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",
|
"sass": "^1.57.1",
|
||||||
"svelte": "^3.55.1"
|
"svelte": "^3.55.1",
|
||||||
|
"svelte-preprocess": "^5.1.3",
|
||||||
|
"tslib": "^2.6.2",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^4.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
1459
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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({})
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,5 +0,0 @@
|
||||||
import App from "../svelte/App.svelte"
|
|
||||||
|
|
||||||
new App({
|
|
||||||
target: document.body
|
|
||||||
})
|
|
|
@ -14,12 +14,12 @@
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="/static/style/downloads.css"
|
href="./style/downloads.scss"
|
||||||
>
|
>
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="/auth/customCSS"
|
href="/api/v1/account/me/css"
|
||||||
>
|
>
|
||||||
|
|
||||||
<link
|
<link
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="/static/style/error.css"
|
href="./style/error.scss"
|
||||||
>
|
>
|
||||||
|
|
||||||
<link
|
<link
|
||||||
|
@ -17,7 +17,7 @@
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="/auth/customCSS"
|
href="/api/v1/account/me/css"
|
||||||
>
|
>
|
||||||
|
|
||||||
<meta
|
<meta
|
38
src/index.html
Normal file
38
src/index.html
Normal 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>
|
|
@ -1,163 +1,133 @@
|
||||||
import cookieParser from "cookie-parser";
|
import { serve } from "@hono/node-server"
|
||||||
import { IntentsBitField, Client } from "discord.js"
|
import { serveStatic } from "@hono/node-server/serve-static"
|
||||||
import express from "express"
|
import { Hono } from "hono"
|
||||||
import fs from "fs"
|
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"
|
const app = new Hono({strict: false})
|
||||||
import Files from "./lib/files"
|
|
||||||
import * as auth from "./lib/auth"
|
|
||||||
import * as Accounts from "./lib/accounts"
|
|
||||||
|
|
||||||
import * as authRoutes from "./routes/authRoutes";
|
app.get(
|
||||||
import * as fileApiRoutes from "./routes/fileApiRoutes";
|
"/static/assets/*",
|
||||||
import * as adminRoutes from "./routes/adminRoutes";
|
serveStatic({
|
||||||
import * as primaryApi from "./routes/primaryApi";
|
rewriteRequestPath: (path) => {
|
||||||
import { getAccount } from "./lib/middleware";
|
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`)
|
app.on(["MOLLER"], "*", async (ctx) => {
|
||||||
let app = express()
|
ctx.header("Content-Type", "image/webp")
|
||||||
let config = require(`${process.cwd()}/config.json`)
|
return ctx.body(await readFile("./assets/moller.png"))
|
||||||
|
})
|
||||||
app.use("/static/assets",express.static("assets"))
|
|
||||||
app.use("/static/style",express.static("out/style"))
|
|
||||||
app.use("/static/js",express.static("out/client"))
|
|
||||||
|
|
||||||
//app.use(bodyParser.text({limit:(config.maxDiscordFileSize*config.maxDiscordFiles)+1048576,type:["application/json","text/plain"]}))
|
//app.use(bodyParser.text({limit:(config.maxDiscordFileSize*config.maxDiscordFiles)+1048576,type:["application/json","text/plain"]}))
|
||||||
|
|
||||||
app.use(cookieParser())
|
|
||||||
|
|
||||||
// check for ssl, if not redirect
|
// check for ssl, if not redirect
|
||||||
if (config.trustProxy) app.enable("trust proxy")
|
if (config.trustProxy) {
|
||||||
|
// app.enable("trust proxy")
|
||||||
|
}
|
||||||
if (config.forceSSL) {
|
if (config.forceSSL) {
|
||||||
app.use((req,res,next) => {
|
app.use(async (ctx, next) => {
|
||||||
if (req.protocol == "http") res.redirect(`https://${req.get("host")}${req.originalUrl}`)
|
if (new URL(ctx.req.url).protocol == "http") {
|
||||||
else next()
|
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
|
// discord
|
||||||
|
let files = new Files(config)
|
||||||
|
|
||||||
let client = new Client({intents:[
|
// ts screams at me if i don't
|
||||||
IntentsBitField.Flags.GuildMessages,
|
// use a function here.
|
||||||
IntentsBitField.Flags.MessageContent
|
// i'm inflight so
|
||||||
],rest:{timeout:config.requestTimeout}})
|
// 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)
|
app.get("/:fileId", async (ctx) =>
|
||||||
adminRoutes.setFilesObj(files)
|
app.fetch(
|
||||||
fileApiRoutes.setFilesObj(files)
|
new Request(
|
||||||
primaryApi.setFilesObj(files)
|
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
|
// index, clone
|
||||||
|
|
||||||
app.get("/", function(req,res) {
|
app.get("/", async (ctx) =>
|
||||||
res.sendFile(process.cwd()+"/pages/index.html")
|
ctx.html(
|
||||||
})
|
await fs.promises.readFile(process.cwd() + "/dist/index.html", "utf-8")
|
||||||
|
)
|
||||||
// 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,"&")
|
|
||||||
.replace(/\</g,"<")
|
|
||||||
.replace(/\>/g,">")
|
|
||||||
)
|
|
||||||
.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")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
routes should be in this order:
|
routes should be in this order:
|
||||||
|
@ -168,10 +138,4 @@ app.get("/download/:fileId", getAccount, (req,res) => {
|
||||||
file serving
|
file serving
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// listen on 3000 or MONOFILE_PORT
|
export default app
|
||||||
|
|
||||||
app.listen(process.env.MONOFILE_PORT || 3000,function() {
|
|
||||||
console.log("Web OK!")
|
|
||||||
})
|
|
||||||
|
|
||||||
client.login(process.env.TOKEN)
|
|
||||||
|
|
236
src/server/lib/DiscordAPI/DiscordRequests.ts
Normal file
236
src/server/lib/DiscordAPI/DiscordRequests.ts
Normal file
File diff suppressed because one or more lines are too long
180
src/server/lib/DiscordAPI/index.ts
Normal file
180
src/server/lib/DiscordAPI/index.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,31 +1,17 @@
|
||||||
import crypto from "crypto"
|
import crypto from "crypto"
|
||||||
import * as auth from "./auth";
|
import * as auth from "./auth.js";
|
||||||
import { readFile, writeFile } from "fs/promises"
|
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
|
// this is probably horrible
|
||||||
// but i don't even care anymore
|
// but i don't even care anymore
|
||||||
|
|
||||||
export let Accounts: Account[] = []
|
export let Db = new DbFile<Account[]>("accounts",[])
|
||||||
|
|
||||||
export interface Account {
|
export type Account = z.infer<typeof AccountSchemas.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Create a new account.
|
* @description Create a new account.
|
||||||
|
@ -35,23 +21,21 @@ export interface Account {
|
||||||
* @returns A Promise which returns the new account's ID
|
* @returns A Promise which returns the new account's ID
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function create(username:string,pwd:string,admin:boolean=false):Promise<string> {
|
export async function create(username:string,pwd:string,admin:boolean=false):Promise<Account> {
|
||||||
return new Promise((resolve,reject) => {
|
let acc: Account = {
|
||||||
let accId = crypto.randomBytes(12).toString("hex")
|
id: crypto.randomUUID(),
|
||||||
|
username: username,
|
||||||
|
password: password.hash(pwd),
|
||||||
|
files: [],
|
||||||
|
admin: admin,
|
||||||
|
defaultFileVisibility: "public",
|
||||||
|
settings: AccountSchemas.Settings.User.parse({})
|
||||||
|
}
|
||||||
|
|
||||||
Accounts.push(
|
Db.data.push(acc)
|
||||||
{
|
await Db.save()
|
||||||
id: accId,
|
|
||||||
username: username,
|
|
||||||
password: password.hash(pwd),
|
|
||||||
files: [],
|
|
||||||
admin: admin,
|
|
||||||
defaultFileVisibility: "public"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
save().then(() => resolve(accId))
|
return acc
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -60,7 +44,7 @@ export function create(username:string,pwd:string,admin:boolean=false):Promise<s
|
||||||
* @returns An Account, if it exists
|
* @returns An Account, if it exists
|
||||||
*/
|
*/
|
||||||
export function getFromUsername(username:string) {
|
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
|
* @returns An Account, if it exists
|
||||||
*/
|
*/
|
||||||
export function getFromId(id:string) {
|
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
|
* @param id The target account's ID
|
||||||
*/
|
*/
|
||||||
export function deleteAccount(id:string) {
|
export function deleteAccount(id:string) {
|
||||||
Accounts.splice(Accounts.findIndex(e => e.id == id),1)
|
Db.data.splice(Db.data.findIndex(e => e.id == id),1)
|
||||||
return save()
|
return Db.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace password {
|
export namespace password {
|
||||||
|
@ -117,11 +101,11 @@ export namespace password {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function set(id:string,password:string) {
|
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
|
if (!acc) return
|
||||||
|
|
||||||
acc.password = hash(password)
|
acc.password = hash(password)
|
||||||
return save()
|
return Db.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -131,7 +115,7 @@ export namespace password {
|
||||||
* @param password Password to check
|
* @param password Password to check
|
||||||
*/
|
*/
|
||||||
export function check(id:string,password:string) {
|
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
|
if (!acc) return
|
||||||
|
|
||||||
return acc.password.hash == hash(password,acc.password.salt).hash
|
return acc.password.hash == hash(password,acc.password.salt).hash
|
||||||
|
@ -145,16 +129,16 @@ export namespace files {
|
||||||
* @param fileId The target file's ID
|
* @param fileId The target file's ID
|
||||||
* @returns Promise that resolves after accounts.json finishes writing
|
* @returns Promise that resolves after accounts.json finishes writing
|
||||||
*/
|
*/
|
||||||
export function index(accountId:string,fileId:string) {
|
export function index(accountId:string,fileId:string,noWrite:boolean = false) {
|
||||||
// maybe replace with a obj like
|
// maybe replace with a obj like
|
||||||
// { x:true }
|
// { x:true }
|
||||||
// for faster lookups? not sure if it would be faster
|
// for faster lookups? not sure if it would be faster
|
||||||
let acc = Accounts.find(e => e.id == accountId)
|
let acc = Db.data.find(e => e.id == accountId)
|
||||||
if (!acc) return
|
if (!acc) return
|
||||||
if (acc.files.find(e => e == fileId)) return
|
if (acc.files.find(e => e == fileId)) return
|
||||||
|
|
||||||
acc.files.push(fileId)
|
acc.files.push(fileId)
|
||||||
return save()
|
if (!noWrite) return Db.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -165,31 +149,29 @@ export namespace files {
|
||||||
* @returns A Promise which resolves when accounts.json finishes writing, if `noWrite` is `false`
|
* @returns A Promise which resolves when accounts.json finishes writing, if `noWrite` is `false`
|
||||||
*/
|
*/
|
||||||
export function deindex(accountId:string,fileId:string, noWrite:boolean=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
|
if (!acc) return
|
||||||
let fi = acc.files.findIndex(e => e == fileId)
|
let fi = acc.files.findIndex(e => e == fileId)
|
||||||
if (fi >= 0) {
|
if (fi >= 0) {
|
||||||
acc.files.splice(fi,1)
|
acc.files.splice(fi,1)
|
||||||
if (!noWrite) return save()
|
if (!noWrite) return Db.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type AccountResolvable = Account | string | `@${string}`
|
||||||
* @description Saves accounts.json
|
|
||||||
* @returns A promise which resolves when accounts.json finishes writing
|
export function resolve(obj: AccountResolvable) {
|
||||||
*/
|
return typeof obj == "object"
|
||||||
export function save() {
|
? obj
|
||||||
return writeFile(`${process.cwd()}/.data/accounts.json`,JSON.stringify(Accounts))
|
: obj.startsWith("@")
|
||||||
.catch((err) => console.error(err))
|
? getFromUsername(obj.slice(1))
|
||||||
|
: getFromId(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
readFile(`${process.cwd()}/.data/accounts.json`)
|
Db.read()
|
||||||
.then((buf) => {
|
.then(() => {
|
||||||
Accounts = JSON.parse(buf.toString())
|
if (!Db.data.find(e => e.admin)) {
|
||||||
}).catch(err => console.error(err))
|
|
||||||
.finally(() => {
|
|
||||||
if (!Accounts.find(e => e.admin)) {
|
|
||||||
create("admin","admin",true)
|
create("admin","admin",true)
|
||||||
}
|
}
|
||||||
})
|
})
|
59
src/server/lib/apply.ts
Normal file
59
src/server/lib/apply.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,106 +1,123 @@
|
||||||
import crypto from "crypto"
|
import crypto from "crypto"
|
||||||
import express from "express"
|
import { getCookie } from "hono/cookie"
|
||||||
|
import type { Context } from "hono"
|
||||||
import { readFile, writeFile } from "fs/promises"
|
import { readFile, writeFile } from "fs/promises"
|
||||||
export let AuthTokens: AuthToken[] = []
|
import { z } from "zod"
|
||||||
export let AuthTokenTO:{[key:string]:NodeJS.Timeout} = {}
|
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 = [
|
export type Scope = z.infer<typeof AuthSchemas.Scope>
|
||||||
"user", // permissions to /auth/me, with email docked
|
export type TokenType = z.infer<typeof AuthSchemas.TokenType>
|
||||||
"email", // adds email back to /auth/me
|
export type AuthToken = z.infer<typeof AuthSchemas.AuthToken>
|
||||||
"private", // allows app to read private files
|
export type TokenResolvable = string | AuthToken
|
||||||
"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 TokenType = "User" | "App"
|
export const Db = new DbFile<AuthToken[]>("tokens", [])
|
||||||
export type TokenPermission = typeof ValidTokenPermissions[number]
|
|
||||||
|
|
||||||
export interface AuthToken {
|
export function resolve(token: TokenResolvable, forCleanup?: boolean) {
|
||||||
account: string,
|
let resolved = typeof token == "object" ? token : Db.data.find(e => e.id == token)
|
||||||
token: string,
|
if (resolved && (forCleanup || resolved.expire == null || Date.now() < resolved.expire))
|
||||||
expire: number,
|
return resolved
|
||||||
|
|
||||||
type?: TokenType, // if !type, assume User
|
|
||||||
tokenPermissions?: TokenPermission[] // default to user if type is App,
|
|
||||||
// give full permissions if type is User
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function create(
|
export function create(
|
||||||
id:string,
|
account: AccountResolvable,
|
||||||
expire:number=(24*60*60*1000),
|
expire: number | null = 24 * 60 * 60 * 1000,
|
||||||
type:TokenType="User",
|
type: TokenType = "User",
|
||||||
tokenPermissions?:TokenPermission[]
|
scopes?: Scope[]
|
||||||
) {
|
) {
|
||||||
let token = {
|
let token = AuthSchemas.AuthToken.parse({
|
||||||
account:id,
|
account: resolveAccount(account)?.id,
|
||||||
token:crypto.randomBytes(36).toString('hex'),
|
id: crypto.randomUUID(),
|
||||||
expire: expire ? Date.now()+expire : 0,
|
expire: typeof expire == "number" ? Date.now() + expire : null,
|
||||||
|
|
||||||
type,
|
type,
|
||||||
tokenPermissions: type == "App" ? tokenPermissions || ["user"] : undefined
|
scopes:
|
||||||
}
|
type != "User" ? scopes || ["user"] : undefined
|
||||||
|
})
|
||||||
|
|
||||||
AuthTokens.push(token)
|
Db.data.push(token)
|
||||||
tokenTimer(token)
|
tokenTimer(token)
|
||||||
|
|
||||||
save()
|
Db.save()
|
||||||
|
|
||||||
return token.token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tokenFor(req: express.Request) {
|
|
||||||
return req.cookies.auth || (
|
export async function getJwtId(jwt: string) {
|
||||||
req.header("authorization")?.startsWith("Bearer ")
|
let result = await jose.jwtVerify(jwt, config.jwtSecret).catch(e => null)
|
||||||
? req.header("authorization")?.split(" ")[1]
|
return result ? result.payload.jti : undefined
|
||||||
: undefined
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken(token:string) {
|
export function makeJwt(_token: TokenResolvable) {
|
||||||
return AuthTokens.find(e => e.token == token && (e.expire == 0 || Date.now() < e.expire))
|
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) {
|
export async function tokenFor(ctx: Context) {
|
||||||
return getToken(token)?.account
|
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 {
|
export function validate(token: TokenResolvable) {
|
||||||
return getToken(token)?.type
|
return resolve(token)?.account
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPermissions(token:string): TokenPermission[] | undefined {
|
export function getType(token: TokenResolvable) {
|
||||||
return getToken(token)?.tokenPermissions
|
return resolve(token)?.type
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tokenTimer(token:AuthToken) {
|
export function getScopes(token: TokenResolvable): Scope[] | undefined {
|
||||||
if (!token.expire) return // justincase
|
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) {
|
if (Date.now() >= token.expire) {
|
||||||
invalidate(token.token)
|
invalidate(token)
|
||||||
return
|
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) {
|
export function invalidate(_token: TokenResolvable) {
|
||||||
if (AuthTokenTO[token]) {
|
let token = resolve(_token, true)!
|
||||||
clearTimeout(AuthTokenTO[token])
|
if (AuthTokenTO[token.id]) {
|
||||||
|
clearTimeout(AuthTokenTO[token.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthTokens.splice(AuthTokens.findIndex(e => e.token == token),1)
|
Db.data.splice(
|
||||||
save()
|
Db.data.indexOf(token),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
Db.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function save() {
|
Db.read()
|
||||||
writeFile(`${process.cwd()}/.data/tokens.json`,JSON.stringify(AuthTokens))
|
.then(() => {
|
||||||
.catch((err) => console.error(err))
|
Db.data.forEach((e) => tokenTimer(e))
|
||||||
}
|
})
|
||||||
|
|
||||||
readFile(`${process.cwd()}/.data/tokens.json`)
|
|
||||||
.then((buf) => {
|
|
||||||
AuthTokens = JSON.parse(buf.toString())
|
|
||||||
AuthTokens.forEach(e => tokenTimer(e))
|
|
||||||
}).catch(err => console.error(err))
|
|
||||||
|
|
88
src/server/lib/codes.ts
Normal file
88
src/server/lib/codes.ts
Normal 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
79
src/server/lib/config.ts
Normal 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
177
src/server/lib/dbfile.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,48 +1,36 @@
|
||||||
import { Response } from "express";
|
|
||||||
import { readFile } from "fs/promises"
|
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
|
* @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 code Error code
|
||||||
* @param reason Error reason
|
* @param reason Error reason
|
||||||
*/
|
*/
|
||||||
export default async function ServeError(
|
export default async function ServeError(
|
||||||
res:Response,
|
ctx: Context,
|
||||||
code:number,
|
code: number,
|
||||||
reason:string
|
reason: string
|
||||||
) {
|
) {
|
||||||
// fetch error page if not cached
|
// fetch error page if not cached
|
||||||
if (!errorPage) {
|
errorPage ??= (
|
||||||
errorPage =
|
(await readFile(`${process.cwd()}/dist/error.html`).catch((err) =>
|
||||||
(
|
console.error(err)
|
||||||
await readFile(`${process.cwd()}/pages/error.html`)
|
)) ?? "<pre>$code $text</pre>"
|
||||||
.catch((err) => console.error(err))
|
).toString()
|
||||||
|| "<pre>$code $text</pre>"
|
|
||||||
)
|
|
||||||
.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// serve error
|
// serve error
|
||||||
res.statusMessage = reason
|
return ctx.req.header("accept")?.includes("text/html") ? ctx.html(
|
||||||
res.status(code)
|
|
||||||
res.header("x-backup-status-message", reason) // glitch default nginx configuration
|
|
||||||
res.send(
|
|
||||||
errorPage
|
errorPage
|
||||||
.replace(/\$code/g,code.toString())
|
.replaceAll("$code", code.toString())
|
||||||
.replace(/\$text/g,reason)
|
.replaceAll("$text", reason),
|
||||||
)
|
code as StatusCode/*,
|
||||||
}
|
{
|
||||||
/**
|
"x-backup-status-message": reason, // glitch default nginx configuration
|
||||||
* @description Redirects a user to another page.
|
}*/
|
||||||
* @param res Express response object
|
) : ctx.text(reason, code as StatusCode)
|
||||||
* @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
35
src/server/lib/invites.ts
Normal 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()
|
|
@ -1,23 +1,17 @@
|
||||||
import { createTransport } from "nodemailer";
|
import { createTransport } from "nodemailer"
|
||||||
|
import config from "./config.js"
|
||||||
|
|
||||||
// required i guess
|
const { mail } = config
|
||||||
require("dotenv").config()
|
const transport = createTransport({
|
||||||
|
host: mail.transport.host,
|
||||||
let
|
port: mail.transport.port,
|
||||||
mailConfig =
|
secure: mail.transport.secure,
|
||||||
require( process.cwd() + "/config.json" ).mail,
|
from: mail.send.from,
|
||||||
transport =
|
auth: {
|
||||||
createTransport(
|
user: mail.user,
|
||||||
{
|
pass: mail.pass,
|
||||||
...mailConfig.transport,
|
},
|
||||||
auth: {
|
})
|
||||||
user: process.env.MAIL_USER,
|
|
||||||
pass: process.env.MAIL_PASS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// lazy but
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Sends an email
|
* @description Sends an email
|
||||||
|
@ -26,20 +20,20 @@ transport =
|
||||||
* @param content Email content
|
* @param content Email content
|
||||||
* @returns Promise which resolves to the output from nodemailer.transport.sendMail
|
* @returns Promise which resolves to the output from nodemailer.transport.sendMail
|
||||||
*/
|
*/
|
||||||
export function sendMail(to: string, subject: string, content: string) {
|
export async function sendMail(to: string, subject: string, content: string) {
|
||||||
return new Promise((resolve,reject) => {
|
if (!config.mail.enabled) return false
|
||||||
transport.sendMail({
|
|
||||||
to,
|
return transport.sendMail({
|
||||||
subject,
|
to,
|
||||||
"from": mailConfig.send.from,
|
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>${
|
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
|
||||||
content
|
.replaceAll(
|
||||||
.replace(/\<span username\>/g, `<span code><span style="color:#DDAA66;padding-right:3px;">@</span>`)
|
"<span username>",
|
||||||
.replace(/\<span code\>/g,`<span style="font-family:monospace;padding:3px 5px 3px 5px;border-radius:8px;background-color:#1C1C1C;color:#DDDDDD;">`)
|
`<span code><span style="color:#DDAA66;padding-right:3px;">@</span>`
|
||||||
}<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) => {
|
.replaceAll(
|
||||||
if (err) reject(err)
|
"<span code>",
|
||||||
else resolve(info)
|
`<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>`,
|
||||||
})
|
})
|
||||||
}
|
}
|
|
@ -1,36 +1,109 @@
|
||||||
import * as Accounts from "./accounts";
|
import * as Accounts from "./accounts.js"
|
||||||
import express, { type RequestHandler } from "express"
|
import type { Context, Hono, Handler as RequestHandler } from "hono"
|
||||||
import ServeError from "../lib/errors";
|
import ServeError from "../lib/errors.js"
|
||||||
import * as auth from "./auth";
|
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) {
|
export const getAccount: RequestHandler = async function (ctx, next) {
|
||||||
res.locals.acc = Accounts.getFromToken(auth.tokenFor(req))
|
let uToken = (await auth.tokenFor(ctx))!
|
||||||
next()
|
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) {
|
export const getTarget: RequestHandler = async (ctx, next) => {
|
||||||
ServeError(res, 401, "not logged in")
|
let tok = await auth.tokenFor(ctx)
|
||||||
return
|
let permissions
|
||||||
}
|
if (tok && auth.getType(tok) != "User")
|
||||||
next()
|
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) {
|
export const accountMgmtRoute: RequestHandler = async (ctx,next) => {
|
||||||
if (!res.locals.acc.admin) {
|
let tok = await auth.tokenFor(ctx)
|
||||||
ServeError(res, 403, "you are not an administrator")
|
let permissions
|
||||||
return
|
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,28 +111,27 @@ export const requiresAdmin: RequestHandler = function(_req, res, next) {
|
||||||
* @param tokenPermissions Permissions which your route requires.
|
* @param tokenPermissions Permissions which your route requires.
|
||||||
* @returns Express middleware
|
* @returns Express middleware
|
||||||
*/
|
*/
|
||||||
|
export const requiresScopes = function (
|
||||||
export const requiresPermissions = function(...tokenPermissions: auth.TokenPermission[]): RequestHandler {
|
...wantsScopes: auth.Scope[]
|
||||||
return function(req, res, next) {
|
): RequestHandler {
|
||||||
let token = auth.tokenFor(req)
|
return async function (ctx, next) {
|
||||||
|
let token = (await auth.tokenFor(ctx))!
|
||||||
let type = auth.getType(token)
|
let type = auth.getType(token)
|
||||||
|
|
||||||
if (type == "App") {
|
if (type != "User") {
|
||||||
let permissions = auth.getPermissions(token)
|
let scopes = auth.getScopes(token)
|
||||||
|
|
||||||
if (!permissions) ServeError(res, 403, "insufficient permissions")
|
if (!scopes) return ServeError(ctx, 403, "insufficient permissions")
|
||||||
else {
|
else {
|
||||||
|
for (let v of wantsScopes) {
|
||||||
for (let v of tokenPermissions) {
|
if (!scopes.includes(v)) {
|
||||||
if (!permissions.includes(v as auth.TokenPermission)) {
|
return ServeError(ctx, 403, "insufficient permissions")
|
||||||
ServeError(res,403,"insufficient permissions")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next()
|
|
||||||
|
|
||||||
}
|
}
|
||||||
} else next()
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,7 +139,87 @@ export const requiresPermissions = function(...tokenPermissions: auth.TokenPermi
|
||||||
* @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) {
|
export const noAPIAccess: RequestHandler = async function (ctx, next) {
|
||||||
if (auth.getType(auth.tokenFor(req)) == "App") ServeError(res, 403, "apps are not allowed to access this endpoint")
|
if (auth.getType((await auth.tokenFor(ctx))!) == "App")
|
||||||
else next()
|
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
|
||||||
|
)
|
6
src/server/lib/package.ts
Normal file
6
src/server/lib/package.ts
Normal 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 }
|
|
@ -1,49 +1,49 @@
|
||||||
import { RequestHandler } from "express"
|
import type { Handler } from "hono"
|
||||||
import { type Account } from "./accounts"
|
import ServeError from "./errors.js"
|
||||||
import ServeError from "./errors"
|
|
||||||
|
|
||||||
interface RatelimitSettings {
|
interface RatelimitSettings {
|
||||||
|
|
||||||
requests: number
|
requests: number
|
||||||
per: 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
|
* @param settings Ratelimit settings
|
||||||
* @returns Express middleware
|
* @returns Express middleware
|
||||||
*/
|
*/
|
||||||
export function accountRatelimit( settings: RatelimitSettings ): RequestHandler {
|
export function accountRatelimit(settings: RatelimitSettings): Handler {
|
||||||
let activeLimits: {
|
let activeLimits: {
|
||||||
[ key: string ]: {
|
[key: string]: {
|
||||||
requests: number,
|
requests: number
|
||||||
expirationHold: NodeJS.Timeout
|
expirationHold: NodeJS.Timeout
|
||||||
}
|
}
|
||||||
} = {}
|
} = {}
|
||||||
|
|
||||||
return (req, res, next) => {
|
return (ctx, next) => {
|
||||||
if (res.locals.acc) {
|
if (ctx.get("account")) {
|
||||||
let accId = res.locals.acc.id
|
let accId = ctx.get("account").id
|
||||||
let aL = activeLimits[accId]
|
let aL = activeLimits[accId]
|
||||||
|
|
||||||
if (!aL) {
|
if (!aL) {
|
||||||
activeLimits[accId] = {
|
activeLimits[accId] = {
|
||||||
requests: 0,
|
requests: 0,
|
||||||
expirationHold: setTimeout(() => delete activeLimits[accId], settings.per)
|
expirationHold: setTimeout(
|
||||||
|
() => delete activeLimits[accId],
|
||||||
|
settings.per
|
||||||
|
),
|
||||||
}
|
}
|
||||||
aL = activeLimits[accId]
|
aL = activeLimits[accId]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aL.requests < settings.requests) {
|
if (aL.requests < settings.requests) {
|
||||||
res.locals.undoCount = () => {
|
ctx.set("undoCount", () => {
|
||||||
if (activeLimits[accId]) {
|
if (activeLimits[accId]) {
|
||||||
activeLimits[accId].requests--
|
activeLimits[accId].requests--
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
next()
|
return next()
|
||||||
} else {
|
} else {
|
||||||
ServeError(res, 429, "too many requests")
|
return ServeError(ctx, 429, "too many requests")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
74
src/server/lib/schemas/accounts.ts
Normal file
74
src/server/lib/schemas/accounts.ts
Normal 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()
|
||||||
|
})
|
40
src/server/lib/schemas/auth.ts
Normal file
40
src/server/lib/schemas/auth.ts
Normal 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"])
|
||||||
|
})
|
||||||
|
])
|
21
src/server/lib/schemas/files.ts
Normal file
21
src/server/lib/schemas/files.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import {z} from "zod"
|
||||||
|
import config from "../config.js"
|
||||||
|
|
||||||
|
export const FileId = z.string()
|
||||||
|
.regex(/^[A-Za-z0-9_\-\.\!\=\:\&\$\,\+\;\@\~\*\(\)\']+$/,"file ID uses invalid characters")
|
||||||
|
.max(config.maxUploadIdLength,"file ID too long")
|
||||||
|
.min(1, "you... *need* a file ID")
|
||||||
|
export const FileVisibility = z.enum(["public", "anonymous", "private"])
|
||||||
|
export const FileTag = z.string().toLowerCase().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())
|
||||||
|
})
|
3
src/server/lib/schemas/index.ts
Normal file
3
src/server/lib/schemas/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * as AccountSchemas from "./accounts.js"
|
||||||
|
export * as FileSchemas from "./files.js"
|
||||||
|
export * as AuthSchemas from "./auth.js"
|
3
src/server/lib/schemas/misc.ts
Normal file
3
src/server/lib/schemas/misc.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const RGBHex = z.string().toLowerCase().length(6).regex(/^[a-f0-9]+$/,"illegal characters")
|
|
@ -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
82
src/server/routes/api.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
219
src/server/routes/api/v0/adminRoutes.ts
Normal file
219
src/server/routes/api/v0/adminRoutes.ts
Normal 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
|
||||||
|
}
|
549
src/server/routes/api/v0/authRoutes.ts
Normal file
549
src/server/routes/api/v0/authRoutes.ts
Normal 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
|
||||||
|
}
|
12
src/server/routes/api/v0/definition.ts
Normal file
12
src/server/routes/api/v0/definition.ts
Normal 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
|
114
src/server/routes/api/v0/fileApiRoutes.ts
Normal file
114
src/server/routes/api/v0/fileApiRoutes.ts
Normal 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
|
||||||
|
}
|
44
src/server/routes/api/v0/primaryApi.ts
Normal file
44
src/server/routes/api/v0/primaryApi.ts
Normal 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
|
||||||
|
}
|
127
src/server/routes/api/v1/account/access.ts
Normal file
127
src/server/routes/api/v1/account/access.ts
Normal 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
|
||||||
|
}
|
396
src/server/routes/api/v1/account/index.ts
Normal file
396
src/server/routes/api/v1/account/index.ts
Normal 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
|
||||||
|
}
|
85
src/server/routes/api/v1/account/prove.ts
Normal file
85
src/server/routes/api/v1/account/prove.ts
Normal 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
|
||||||
|
}
|
41
src/server/routes/api/v1/definition.ts
Normal file
41
src/server/routes/api/v1/definition.ts
Normal 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
|
240
src/server/routes/api/v1/file/index.ts
Normal file
240
src/server/routes/api/v1/file/index.ts
Normal 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
|
||||||
|
}
|
158
src/server/routes/api/v1/file/individual.ts
Normal file
158
src/server/routes/api/v1/file/individual.ts
Normal 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
|
||||||
|
}
|
29
src/server/routes/api/v1/file/schemes.ts
Normal file
29
src/server/routes/api/v1/file/schemes.ts
Normal 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()
|
||||||
|
})
|
30
src/server/routes/api/v1/index.ts
Normal file
30
src/server/routes/api/v1/index.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import * as Accounts from "../../../lib/accounts.js"
|
||||||
|
import { HttpBindings } from "@hono/node-server"
|
||||||
|
import 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
|
||||||
|
}
|
40
src/server/routes/api/v1/server/invites.ts
Normal file
40
src/server/routes/api/v1/server/invites.ts
Normal 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
|
||||||
|
}
|
68
src/server/routes/api/v1/server/run.ts
Normal file
68
src/server/routes/api/v1/server/run.ts
Normal 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
|
||||||
|
}
|
70
src/server/routes/api/v1/session.ts
Normal file
70
src/server/routes/api/v1/session.ts
Normal 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
|
||||||
|
}
|
7
src/server/routes/api/web/definition.ts
Normal file
7
src/server/routes/api/web/definition.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { APIDefinition } from "../../api.js";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
"name": "web",
|
||||||
|
"baseURL": "/",
|
||||||
|
"mount": [{ "file": "preview", "to": "/download" }, "go"]
|
||||||
|
} satisfies APIDefinition
|
40
src/server/routes/api/web/go.ts
Normal file
40
src/server/routes/api/web/go.ts
Normal 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
|
||||||
|
}
|
114
src/server/routes/api/web/preview.ts
Normal file
114
src/server/routes/api/web/preview.ts
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
)
|
||||||
|
.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
|
||||||
|
}
|
|
@ -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("")
|
|
||||||
})
|
|
|
@ -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))
|
|
||||||
|
|
||||||
|
|
||||||
})
|
|
|
@ -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
112
src/server/tools/cli.ts
Normal 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
107
src/server/tsconfig.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
.pulldown_display[name=accounts] {
|
.pulldown_display[data-name=accounts] {
|
||||||
.notLoggedIn {
|
.notLoggedIn {
|
||||||
.container_div {
|
.container_div {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
|
@ -185,3 +185,41 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
.pulldown_display[name=files] {
|
.pulldown_display[data-name=files] {
|
||||||
.notLoggedIn {
|
.notLoggedIn {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
top:50%;
|
top:50%;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
.pulldown_display[name=help] {
|
.pulldown_display[data-name=help] {
|
||||||
|
|
||||||
overflow-y:auto;
|
overflow-y:auto;
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
span {
|
span {
|
||||||
position:relative;
|
position:relative;
|
||||||
|
|
||||||
&._add_files_txt {
|
&.add_files_txt {
|
||||||
font-size:16px;
|
font-size:16px;
|
||||||
top:-4px;
|
top:-4px;
|
||||||
left:10px;
|
left:10px;
|
||||||
|
@ -29,7 +29,7 @@
|
||||||
@media screen and (max-width:500px) {
|
@media screen and (max-width:500px) {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
|
|
||||||
span._add_files_txt {
|
span.add_files_txt {
|
||||||
font-size:20px;
|
font-size:20px;
|
||||||
top:-6px;
|
top:-6px;
|
||||||
left:10px;
|
left:10px;
|
||||||
|
@ -45,7 +45,7 @@
|
||||||
flex-direction:row;
|
flex-direction:row;
|
||||||
column-gap:10px;
|
column-gap:10px;
|
||||||
|
|
||||||
button, input[type=text] {
|
button, input[type=text], input[type=submit] {
|
||||||
background-color:#333333;
|
background-color:#333333;
|
||||||
color:#DDDDDD;
|
color:#DDDDDD;
|
||||||
border:none;
|
border:none;
|
||||||
|
@ -63,7 +63,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button, input[type=submit] {
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
overflow:auto;
|
overflow:auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button, input[type=submit] {
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
background-color:#393939;
|
background-color:#393939;
|
||||||
color:#DDDDDD;
|
color:#DDDDDD;
|
||||||
|
|
|
@ -1,19 +1,13 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import Topbar from "./elem/Topbar.svelte";
|
import Topbar from "./elem/Topbar.svelte";
|
||||||
import PulldownManager from "./elem/PulldownManager.svelte";
|
import PulldownManager from "./elem/PulldownManager.svelte";
|
||||||
import UploadWindow from "./elem/UploadWindow.svelte";
|
import UploadWindow from "./elem/UploadWindow.svelte";
|
||||||
import { pulldownManager } from "./elem/stores.mjs";
|
import { pulldownManager } from "./elem/stores.js";
|
||||||
|
|
||||||
/**
|
let topbar: Topbar;
|
||||||
* @type Topbar
|
|
||||||
*/
|
|
||||||
let topbar;
|
|
||||||
|
|
||||||
/**
|
let pulldown: PulldownManager;
|
||||||
* @type PulldownManager
|
|
||||||
*/
|
|
||||||
let pulldown;
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
pulldownManager.set(pulldown)
|
pulldownManager.set(pulldown)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<script context="module">
|
<script context="module" lang="ts">
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
// can't find a better way to do this
|
// can't find a better way to do this
|
||||||
|
@ -13,10 +13,10 @@
|
||||||
.set("help",Help)
|
.set("help",Help)
|
||||||
.set("files",Files)
|
.set("files",Files)
|
||||||
|
|
||||||
export const pulldownOpen = writable(false);
|
export const pulldownOpen = writable<string|false>(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { fade, scale } from "svelte/transition";
|
import { fade, scale } from "svelte/transition";
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@
|
||||||
return $pulldownOpen
|
return $pulldownOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openPulldown(name) {
|
export function openPulldown(name: string) {
|
||||||
pulldownOpen.set(name)
|
pulldownOpen.set(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { circOut } from "svelte/easing";
|
import { circOut } from "svelte/easing";
|
||||||
import { scale } from "svelte/transition";
|
import { scale } from "svelte/transition";
|
||||||
import PulldownManager, {pulldownOpen} from "./PulldownManager.svelte";
|
import PulldownManager, {pulldownOpen} from "./PulldownManager.svelte";
|
||||||
import { account } from "./stores.mjs";
|
import { account } from "./stores.js";
|
||||||
import { _void } from "./transition/_void";
|
import { _void } from "./transition/_void.js";
|
||||||
|
|
||||||
/**
|
export let pulldown: PulldownManager;
|
||||||
* @type PulldownManager
|
|
||||||
*/
|
|
||||||
export let pulldown;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="topbar">
|
<div id="topbar">
|
||||||
|
@ -23,7 +20,7 @@
|
||||||
<!-- too lazy to make this better -->
|
<!-- too lazy to make this better -->
|
||||||
|
|
||||||
<button class="menuBtn" on:click={() => pulldown.openPulldown("files")}>files</button>
|
<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>
|
<button class="menuBtn" on:click={() => pulldown.openPulldown("help")}>help</button>
|
||||||
|
|
||||||
<div /> <!-- not sure what's offcenter but something is
|
<div /> <!-- not sure what's offcenter but something is
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { _void } from "./transition/_void.js"
|
import { _void } from "./transition/_void.js"
|
||||||
import { padding_scaleY } from "./transition/padding_scaleY.js"
|
import { padding_scaleY } from "./transition/padding_scaleY.js"
|
||||||
import { fade } from "svelte/transition"
|
import { fade } from "svelte/transition"
|
||||||
import { circIn, circOut } from "svelte/easing"
|
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"
|
import AttachmentZone from "./uploader/AttachmentZone.svelte"
|
||||||
|
|
||||||
|
@ -13,56 +14,48 @@
|
||||||
|
|
||||||
// uploads
|
// uploads
|
||||||
|
|
||||||
|
interface Upload {
|
||||||
|
file: string | File
|
||||||
|
|
||||||
|
params: {
|
||||||
|
uploadId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadStatus: {
|
||||||
|
fileId?: string,
|
||||||
|
error?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
maximized?: boolean,
|
||||||
|
viewingUrl?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
let attachmentZone
|
let attachmentZone
|
||||||
let uploads = {}
|
let uploads: Record<string, Upload> = {}
|
||||||
let uploadInProgress = false
|
let uploadInProgress = false
|
||||||
let notificationPermission =
|
let notificationPermission =
|
||||||
globalThis?.Notification?.permission ?? "denied"
|
globalThis?.Notification?.permission ?? "denied"
|
||||||
let handle_file_upload = (ev) => {
|
let handle_file_upload = (file: Event & { detail: File|string }) => {
|
||||||
if (ev.detail.type == "clone") {
|
|
||||||
uploads[Math.random().toString().slice(2)] = {
|
|
||||||
type: "clone",
|
|
||||||
name: ev.detail.url,
|
|
||||||
url: ev.detail.url,
|
|
||||||
|
|
||||||
params: {
|
uploads[Math.random().toString().slice(2)] = {
|
||||||
uploadId: "",
|
file: file.detail,
|
||||||
},
|
|
||||||
|
|
||||||
uploadStatus: {
|
params: {
|
||||||
fileId: null,
|
uploadId: "",
|
||||||
error: null,
|
},
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
uploads = uploads
|
uploadStatus: {}
|
||||||
} 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploads = uploads
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let handle_fetch_promise = (x, prom) => {
|
let handle_fetch_promise = (x: string, prom: Promise<Response>) => {
|
||||||
return prom
|
return prom
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
let txt = await res.text()
|
let txt = await res.text()
|
||||||
if (txt.startsWith("[err]")) uploads[x].uploadStatus.error = txt
|
if (!res.ok) uploads[x].uploadStatus.error = txt
|
||||||
else {
|
else {
|
||||||
uploads[x].uploadStatus.fileId = txt
|
uploads[x].uploadStatus.fileId = txt
|
||||||
try {
|
try {
|
||||||
|
@ -80,8 +73,8 @@
|
||||||
],
|
],
|
||||||
}).addEventListener(
|
}).addEventListener(
|
||||||
"notificationclick",
|
"notificationclick",
|
||||||
({ action }) => {
|
(event) => {
|
||||||
if (action === "open") {
|
if ("action" in event && event.action === "open") {
|
||||||
open(
|
open(
|
||||||
"/download/" +
|
"/download/" +
|
||||||
uploads[x].uploadStatus.fileId
|
uploads[x].uploadStatus.fileId
|
||||||
|
@ -112,35 +105,14 @@
|
||||||
// quick patch-in to allow for a switch to have everything upload sequentially
|
// 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
|
// switch will have a proper menu option later, for now i'm lazy so it's just gonna be a Secret
|
||||||
let hdl = () => {
|
let hdl = () => {
|
||||||
switch (v.type) {
|
let fd = new FormData()
|
||||||
case "upload":
|
if (v.params.uploadId) fd.append("uploadId", v.params.uploadId)
|
||||||
let fd = new FormData()
|
fd.append("file", v.file)
|
||||||
fd.append("file", v.file)
|
|
||||||
|
|
||||||
return handle_fetch_promise(
|
return handle_fetch_promise(x,fetch("/api/v1/file",{
|
||||||
x,
|
method: "PUT",
|
||||||
fetch("/upload", {
|
body: fd
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sequential) await hdl()
|
if (sequential) await hdl()
|
||||||
|
@ -150,10 +122,10 @@
|
||||||
|
|
||||||
// animation
|
// animation
|
||||||
|
|
||||||
function fileTransition(node) {
|
function fileTransition(node: HTMLElement) {
|
||||||
return {
|
return {
|
||||||
duration: 300,
|
duration: 300,
|
||||||
css: (t) => {
|
css: (t: number) => {
|
||||||
let eased = circOut(t)
|
let eased = circOut(t)
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
@ -195,7 +167,7 @@
|
||||||
</h1>
|
</h1>
|
||||||
<p style:color="#999999">
|
<p style:color="#999999">
|
||||||
<span class="number"
|
<span class="number"
|
||||||
>{$serverStats.version ? `v${$serverStats.version}` : "•••"}</span
|
>{$serverStats?.version ? `v${$serverStats?.version}` : "•••"}</span
|
||||||
> — Discord based file sharing
|
> — Discord based file sharing
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
@ -215,14 +187,9 @@
|
||||||
: ""}
|
: ""}
|
||||||
>
|
>
|
||||||
<h2>
|
<h2>
|
||||||
{upload[1].name}
|
{typeof upload[1].file == "string" ? upload[1].file : upload[1].file.name}
|
||||||
<span style:color="#999999" style:font-weight="400"
|
<span style:color="#999999" style:font-weight="400"
|
||||||
>{upload[1].type}{@html upload[1].type == "upload"
|
>{@html typeof upload[1].file == "string" ? "clone" : `upload (${bytes(upload[1].file.size)})`}</span>
|
||||||
? ` (${Math.round(
|
|
||||||
upload[1].file.size / 1048576
|
|
||||||
)}MiB)`
|
|
||||||
: ""}</span
|
|
||||||
>
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{#if upload[1].maximized && !uploadInProgress}
|
{#if upload[1].maximized && !uploadInProgress}
|
||||||
|
@ -341,7 +308,7 @@
|
||||||
|
|
||||||
{#if uploadInProgress == false}
|
{#if uploadInProgress == false}
|
||||||
<!-- if required for upload, check if logged in -->
|
<!-- if required for upload, check if logged in -->
|
||||||
{#if ($serverStats.accounts || {}).requiredForUpload ? !!$account.username : true}
|
{#if $serverStats?.accounts?.requiredForUpload ? !!$account?.username : true}
|
||||||
<AttachmentZone
|
<AttachmentZone
|
||||||
bind:this={attachmentZone}
|
bind:this={attachmentZone}
|
||||||
on:addFiles={handle_file_upload}
|
on:addFiles={handle_file_upload}
|
||||||
|
@ -374,14 +341,15 @@
|
||||||
|
|
||||||
<p style:color="#999999" style:text-align="center">
|
<p style:color="#999999" style:text-align="center">
|
||||||
Hosting <span class="number" style:font-weight="600"
|
Hosting <span class="number" style:font-weight="600"
|
||||||
>{$serverStats.files || "•••"}</span
|
>{$serverStats?.files ?? "•••"}</span
|
||||||
>
|
>
|
||||||
files — Maximum filesize is
|
files — Maximum filesize is
|
||||||
<span class="number" style:font-weight="600"
|
<span class="number" style:font-weight="600">
|
||||||
>{(($serverStats.maxDiscordFileSize || 0) *
|
{
|
||||||
($serverStats.maxDiscordFiles || 0)) /
|
$serverStats?.maxDiscordFiles
|
||||||
1048576 || "•••"}MiB</span
|
? bytes($serverStats.maxDiscordFileSize * $serverStats.maxDiscordFiles)
|
||||||
>
|
: "•••"
|
||||||
|
}</span>
|
||||||
<br />
|
<br />
|
||||||
</p>
|
</p>
|
||||||
<p style:color="#999999" style:text-align="center" style:font-size="12px">
|
<p style:color="#999999" style:text-align="center" style:font-size="12px">
|
||||||
|
|
|
@ -1,28 +1,30 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { fade, slide } from "svelte/transition";
|
import { fade, slide } from "svelte/transition";
|
||||||
|
|
||||||
|
interface BaseModalOption {
|
||||||
|
name:string,
|
||||||
|
icon:string,
|
||||||
|
id: string | number | symbol | boolean
|
||||||
|
}
|
||||||
|
|
||||||
let activeModal;
|
type ModalOption = BaseModalOption & {inputSettings: {password?: boolean}, id: any} | BaseModalOption & { description: string }
|
||||||
let modalResults;
|
|
||||||
|
|
||||||
/**
|
type ModalOptions = ModalOption[]
|
||||||
*
|
type OptionPickerReturns = {selected: any} & Record<any,any> | null
|
||||||
* @param mdl {name:string,icon:string,description:string,id:string}[]
|
let activeModal: {resolve: (val: OptionPickerReturns) => void, title: string, modal: ModalOptions } | undefined;
|
||||||
* @returns Promise
|
let modalResults: Record<string | number | symbol, string> = {};
|
||||||
*/
|
|
||||||
export function picker(title,mdl) {
|
export function picker(title: string,mdl: ModalOptions): Promise<OptionPickerReturns> {
|
||||||
if (activeModal) forceCancel()
|
if (activeModal) forceCancel()
|
||||||
|
|
||||||
return new Promise((resolve,reject) => {
|
return new Promise<OptionPickerReturns>((resolve,reject) => {
|
||||||
activeModal = {
|
activeModal = {
|
||||||
resolve,
|
resolve,
|
||||||
title,
|
title,
|
||||||
modal:mdl
|
modal:mdl
|
||||||
}
|
}
|
||||||
|
|
||||||
modalResults = {
|
modalResults = {}
|
||||||
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,7 +32,7 @@
|
||||||
if (activeModal && activeModal.resolve) {
|
if (activeModal && activeModal.resolve) {
|
||||||
activeModal.resolve(null)
|
activeModal.resolve(null)
|
||||||
}
|
}
|
||||||
activeModal = null
|
activeModal = undefined
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -46,9 +48,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each activeModal.modal as option (option.id)}
|
{#each activeModal.modal as option (option.id)}
|
||||||
{#if option.inputSettings}
|
{#if "inputSettings" in option}
|
||||||
<div class="inp">
|
<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 -->
|
<!-- i have to do this stupidness because of svelte but -->
|
||||||
<!-- its reason for blocking this is pretty good sooooo -->
|
<!-- its reason for blocking this is pretty good sooooo -->
|
||||||
|
@ -60,8 +62,8 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button on:click={() => {activeModal.resolve({...modalResults,selected:option.id});activeModal=null;modalResults=null;}}>
|
<button on:click={() => {activeModal?.resolve({...modalResults,selected:option.id});activeModal=undefined;modalResults={};}}>
|
||||||
<img src={option.icon} alt={option.id}>
|
<img src={option.icon} alt={option.id.toString()}>
|
||||||
<p>{option.name}<span><br />{option.description}</span></p>
|
<p>{option.name}<span><br />{option.description}</span></p>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { fetchAccountData, account, refreshNeeded } from "../stores.mjs"
|
import { fetchAccountData, account, refreshNeeded } from "../stores"
|
||||||
import { get } from "svelte/store";
|
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?",[
|
optPicker.picker("What should we do with your files?",[
|
||||||
{
|
{
|
||||||
name: "Delete my 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",[
|
optPicker.picker("Change username",[
|
||||||
{
|
{
|
||||||
name: "New 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?",[
|
optPicker.picker("Forgot your password?",[
|
||||||
{
|
{
|
||||||
name: "Username",
|
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?",[
|
optPicker.picker("What would you like to do?",[
|
||||||
{
|
{
|
||||||
name: "Set a new email",
|
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",[
|
optPicker.picker("Change email",[
|
||||||
{
|
{
|
||||||
name: "New 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",[
|
optPicker.picker("Change password",[
|
||||||
{
|
{
|
||||||
name: "New 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",[
|
optPicker.picker("Set custom CSS",[
|
||||||
{
|
{
|
||||||
name: "Enter a file ID",
|
name: "Enter a file ID",
|
||||||
|
@ -225,23 +226,32 @@ export function customcss(optPicker) {
|
||||||
}
|
}
|
||||||
]).then((exp) => {
|
]).then((exp) => {
|
||||||
if (exp && exp.selected) {
|
if (exp && exp.selected) {
|
||||||
fetch(`/auth/customcss`,{method:"POST", body:JSON.stringify({
|
fetch(`/api/v1/account/customization/css`, {
|
||||||
fileId:exp.fileid
|
method: "PUT",
|
||||||
})}).then((response) => {
|
body: JSON.stringify({
|
||||||
|
fileId: exp.fileid,
|
||||||
|
}),
|
||||||
|
}).then((response) => {
|
||||||
if (response.status != 200) {
|
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()
|
fetchAccountData()
|
||||||
refreshNeeded.set(true);
|
refreshNeeded.set(true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function embedColor(optPicker) {
|
export function embedColor(optPicker: OptionPicker) {
|
||||||
optPicker.picker("Set embed color",[
|
optPicker.picker("Set embed color",[
|
||||||
{
|
{
|
||||||
name: "FFFFFF",
|
name: "FFFFFF",
|
||||||
|
@ -257,12 +267,21 @@ export function embedColor(optPicker) {
|
||||||
}
|
}
|
||||||
]).then((exp) => {
|
]).then((exp) => {
|
||||||
if (exp && exp.selected) {
|
if (exp && exp.selected) {
|
||||||
fetch(`/auth/embedcolor`,{method:"POST", body:JSON.stringify({
|
fetch(`/api/v1/account/customization/embed/color`, {
|
||||||
color:exp.color
|
method: "POST",
|
||||||
})}).then((response) => {
|
body: JSON.stringify({
|
||||||
|
color: exp.color,
|
||||||
|
}),
|
||||||
|
}).then((response) => {
|
||||||
if (response.status != 200) {
|
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()
|
fetchAccountData()
|
||||||
|
@ -272,7 +291,7 @@ export function embedColor(optPicker) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function embedSize(optPicker) {
|
export function embedSize(optPicker: OptionPicker) {
|
||||||
optPicker.picker("Set embed image size",[
|
optPicker.picker("Set embed image size",[
|
||||||
{
|
{
|
||||||
name: "Large",
|
name: "Large",
|
||||||
|
@ -288,12 +307,21 @@ export function embedSize(optPicker) {
|
||||||
}
|
}
|
||||||
]).then((exp) => {
|
]).then((exp) => {
|
||||||
if (exp && exp.selected !== null) {
|
if (exp && exp.selected !== null) {
|
||||||
fetch(`/auth/embedsize`,{method:"POST", body:JSON.stringify({
|
fetch(`/api/v1/account/customization/embed/size`, {
|
||||||
largeImage:exp.selected
|
method: "POST",
|
||||||
})}).then((response) => {
|
body: JSON.stringify({
|
||||||
|
largeImage: exp.selected,
|
||||||
|
}),
|
||||||
|
}).then((response) => {
|
||||||
if (response.status != 200) {
|
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()
|
fetchAccountData()
|
|
@ -1,7 +1,8 @@
|
||||||
import { fetchAccountData, fetchFilePointers, account } from "../stores.mjs"
|
import { fetchAccountData, fetchFilePointers, account } from "../stores"
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
import type OptionPicker from "./OptionPicker.svelte";
|
||||||
|
|
||||||
export function pwdReset(optPicker) {
|
export function pwdReset(optPicker: OptionPicker) {
|
||||||
optPicker.picker("Reset password",[
|
optPicker.picker("Reset password",[
|
||||||
{
|
{
|
||||||
name: "Target user",
|
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",[
|
optPicker.picker("Transfer file ownership",[
|
||||||
{
|
{
|
||||||
name: "File ID",
|
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",[
|
optPicker.picker("Change file ID",[
|
||||||
{
|
{
|
||||||
name: "Target file",
|
name: "Target file",
|
||||||
|
@ -111,7 +112,7 @@ export function chgId(optPicker) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function delFile(optPicker) {
|
export function delFile(optPicker: OptionPicker) {
|
||||||
optPicker.picker("Delete file",[
|
optPicker.picker("Delete file",[
|
||||||
{
|
{
|
||||||
name: "File ID",
|
name: "File ID",
|
||||||
|
@ -140,7 +141,7 @@ export function delFile(optPicker) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function elevateUser(optPicker) {
|
export function elevateUser(optPicker: OptionPicker) {
|
||||||
optPicker.picker("Elevate user",[
|
optPicker.picker("Elevate user",[
|
||||||
{
|
{
|
||||||
name: "Username",
|
name: "Username",
|
||||||
|
@ -171,7 +172,7 @@ export function elevateUser(optPicker) {
|
||||||
|
|
||||||
// im really lazy so i just stole this from account.js
|
// 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?",[
|
optPicker.picker("What should we do with the target account's files?",[
|
||||||
{
|
{
|
||||||
name: "Delete files",
|
name: "Delete files",
|
|
@ -1,5 +1,7 @@
|
||||||
import { fetchAccountData, fetchFilePointers, account } from "../stores.mjs"
|
import { fetchAccountData, fetchFilePointers, account } from "../stores"
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
import type OptionPicker from "./OptionPicker.svelte"
|
||||||
|
import type { FilePointer } from "../../../server/lib/files";
|
||||||
|
|
||||||
export let options = {
|
export let options = {
|
||||||
FV: [
|
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) => {
|
optPicker.picker("Default file visibility",options.FV).then((exp) => {
|
||||||
if (exp && exp.selected) {
|
if (exp && exp.selected) {
|
||||||
fetch(`/auth/dfv`,{method:"POST", body:JSON.stringify({
|
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?",[
|
optPicker.picker("You sure?",[
|
||||||
{
|
{
|
||||||
name: "Yeah",
|
name: "Yeah",
|
||||||
icon: "/static/assets/icons/update.svg",
|
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
|
id: true
|
||||||
}
|
}
|
||||||
]).then((exp) => {
|
]).then((exp) => {
|
||||||
if (exp && exp.selected) {
|
if (exp && exp.selected) {
|
||||||
fetch(`/files/manage`,{method:"POST", body:JSON.stringify({
|
fetch(`/files/manage`,{method:"POST", body:JSON.stringify({
|
||||||
target:get(account).files,
|
target:get(account)?.files,
|
||||||
action: "changeFileVisibility",
|
action: "changeFileVisibility",
|
||||||
|
|
||||||
value: get(account).defaultFileVisibility
|
value: get(account)?.defaultFileVisibility
|
||||||
})}).then((response) => {
|
})}).then((response) => {
|
||||||
|
|
||||||
if (response.status != 200) {
|
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,[
|
optPicker.picker(file.filename,[
|
||||||
{
|
{
|
||||||
name: file.tag ? "Remove tag" : "Tag file",
|
name: file.tag ? "Remove tag" : "Tag file",
|
|
@ -1,26 +1,26 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import Pulldown from "./Pulldown.svelte"
|
import Pulldown from "./Pulldown.svelte"
|
||||||
import { padding_scaleY } from "../transition/padding_scaleY"
|
import { padding_scaleY } from "../transition/padding_scaleY"
|
||||||
import { circIn,circOut } from "svelte/easing"
|
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 { fade } from "svelte/transition";
|
||||||
import OptionPicker from "../prompts/OptionPicker.svelte";
|
import OptionPicker from "../prompts/OptionPicker.svelte";
|
||||||
import * as accOpts from "../prompts/account";
|
import * as accOpts from "../prompts/account";
|
||||||
import * as uplOpts from "../prompts/uploads";
|
import * as uplOpts from "../prompts/uploads";
|
||||||
import * as admOpts from "../prompts/admin";
|
import * as admOpts from "../prompts/admin";
|
||||||
|
|
||||||
let targetAction
|
let targetAction: "login"|"create"
|
||||||
let inProgress
|
let inProgress: boolean
|
||||||
let authError
|
let authError:{status:number,message:string}|undefined
|
||||||
|
|
||||||
let pwErr
|
let pwErr: HTMLDivElement
|
||||||
|
|
||||||
let optPicker;
|
let optPicker: OptionPicker;
|
||||||
|
|
||||||
// lazy
|
// lazy
|
||||||
|
|
||||||
let username
|
let username: string
|
||||||
let password
|
let password: string
|
||||||
|
|
||||||
let execute = () => {
|
let execute = () => {
|
||||||
if (inProgress) return
|
if (inProgress) return
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
authError = null, username = "", password = "";
|
authError = undefined, username = "", password = "";
|
||||||
fetchAccountData();
|
fetchAccountData();
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
@ -66,55 +66,7 @@
|
||||||
|
|
||||||
<Pulldown name="accounts">
|
<Pulldown name="accounts">
|
||||||
<OptionPicker bind:this={optPicker} />
|
<OptionPicker bind:this={optPicker} />
|
||||||
{#if Object.keys($account).length == 0}
|
{#if $account}
|
||||||
|
|
||||||
<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}
|
|
||||||
|
|
||||||
<div class="loggedIn" transition:fade={{duration:200}}>
|
<div class="loggedIn" transition:fade={{duration:200}}>
|
||||||
<h1>
|
<h1>
|
||||||
Hey there, <span class="monospace">@{$account.username}</span>
|
Hey there, <span class="monospace">@{$account.username}</span>
|
||||||
|
@ -131,7 +83,7 @@
|
||||||
<p>Change username</p>
|
<p>Change username</p>
|
||||||
</button>
|
</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">
|
<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>
|
<p>Change email{#if $account.email}<span class="monospaceText"><br />{$account.email}</span>{/if}</p>
|
||||||
</button>
|
</button>
|
||||||
|
@ -182,7 +134,7 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if $refreshNeeded}
|
{#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">
|
<img src="/static/assets/icons/refresh.svg" alt="refresh">
|
||||||
<p>Refresh<span><br />Changes were made which require a refresh</span></p>
|
<p>Refresh<span><br />Changes were made which require a refresh</span></p>
|
||||||
</button>
|
</button>
|
||||||
|
@ -194,12 +146,12 @@
|
||||||
|
|
||||||
<button on:click={() => fetch(`/auth/logout_sessions`,{method:"POST"}).then(() => fetchAccountData())}>
|
<button on:click={() => fetch(`/auth/logout_sessions`,{method:"POST"}).then(() => fetchAccountData())}>
|
||||||
<img src="/static/assets/icons/logout_all.svg" alt="logout_all">
|
<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>
|
||||||
|
|
||||||
<button on:click={() => fetch(`/auth/logout`,{method:"POST"}).then(() => fetchAccountData())}>
|
<button on:click={() => fetch(`/auth/logout`,{method:"POST"}).then(() => fetchAccountData())}>
|
||||||
<img src="/static/assets/icons/logout.svg" alt="logout">
|
<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>
|
</button>
|
||||||
|
|
||||||
{#if $account.admin}
|
{#if $account.admin}
|
||||||
|
@ -242,6 +194,50 @@
|
||||||
<p style="font-size:12px;color:#AAAAAA;text-align:center;" class="monospace"><br />{$account.id}</p>
|
<p style="font-size:12px;color:#AAAAAA;text-align:center;" class="monospace"><br />{$account.id}</p>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
</Pulldown>
|
</Pulldown>
|
|
@ -1,13 +1,13 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import Pulldown from "./Pulldown.svelte";
|
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 { fade } from "svelte/transition";
|
||||||
import { flip } from "svelte/animate";
|
import { flip } from "svelte/animate";
|
||||||
import { fileOptions } from "../prompts/uploads";
|
import { fileOptions } from "../prompts/uploads";
|
||||||
import OptionPicker from "../prompts/OptionPicker.svelte";
|
import OptionPicker from "../prompts/OptionPicker.svelte";
|
||||||
|
|
||||||
let picker;
|
let picker: OptionPicker;
|
||||||
let query = "";
|
let query = "";
|
||||||
|
|
||||||
fetchFilePointers();
|
fetchFilePointers();
|
||||||
|
@ -17,48 +17,47 @@
|
||||||
|
|
||||||
<OptionPicker bind:this={picker} />
|
<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"} />
|
||||||
|
<span class="number">{file.id}</span> — <span class="cd">{file.mime.split(";")[0]}</span>
|
||||||
|
{#if file.reserved}
|
||||||
|
<br />
|
||||||
|
<img src="/static/assets/icons/update.svg" alt="uploading"/>
|
||||||
|
Uploading...
|
||||||
|
{/if}
|
||||||
|
{#if file.tag}
|
||||||
|
<br />
|
||||||
|
<img src="/static/assets/icons/tag.svg" alt="tag"/>
|
||||||
|
<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 class="notLoggedIn">
|
||||||
<div style:height="10px" />
|
<div style:height="10px" />
|
||||||
<p class="flavor">Log in to view uploads</p>
|
<p class="flavor">Log in to view uploads</p>
|
||||||
<button on:click={$pulldownManager.openPulldown("account")}>OK</button>
|
<button on:click={$pulldownManager.openPulldown("account")}>OK</button>
|
||||||
<div style:height="14px" />
|
<div style:height="14px" />
|
||||||
</div>
|
</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"} />
|
|
||||||
<span class="number">{file.id}</span> — <span class="cd">{file.mime.split(";")[0]}</span>
|
|
||||||
{#if file.reserved}
|
|
||||||
<br />
|
|
||||||
<img src="/static/assets/icons/update.svg" alt="uploading"/>
|
|
||||||
Uploading...
|
|
||||||
{/if}
|
|
||||||
{#if file.tag}
|
|
||||||
<br />
|
|
||||||
<img src="/static/assets/icons/tag.svg" alt="tag"/>
|
|
||||||
<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}
|
{/if}
|
||||||
|
|
||||||
</Pulldown>
|
</Pulldown>
|
|
@ -1,13 +1,13 @@
|
||||||
<script>
|
<script lang=ts>
|
||||||
|
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
|
|
||||||
export let name;
|
export let name: string;
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<div
|
<div
|
||||||
class="pulldown_display"
|
class="pulldown_display"
|
||||||
name={name}
|
data-name={name}
|
||||||
transition:fade={{duration:150}}
|
transition:fade={{duration:150}}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
|
@ -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
54
src/svelte/elem/stores.ts
Normal 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()
|
|
@ -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;
|
|
||||||
`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
23
src/svelte/elem/transition/_void.ts
Normal file
23
src/svelte/elem/transition/_void.ts
Normal 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;
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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};` : ""}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
21
src/svelte/elem/transition/padding_scaleY.ts
Normal file
21
src/svelte/elem/transition/padding_scaleY.ts
Normal 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}
|
|
@ -1,56 +1,35 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
import { createEventDispatcher } from "svelte";
|
||||||
import { circIn, circOut } from "svelte/easing"
|
import { circOut } from "svelte/easing"
|
||||||
import { fade } from "svelte/transition";
|
|
||||||
import { _void } from "../transition/_void"
|
import { _void } from "../transition/_void"
|
||||||
|
|
||||||
let uploadTypes = {
|
enum UploadTypes {
|
||||||
files: 1,
|
None,
|
||||||
clone: 2
|
Files,
|
||||||
|
Clone
|
||||||
}
|
}
|
||||||
|
|
||||||
let uploadType = undefined
|
let uploadType: UploadTypes = UploadTypes.None
|
||||||
let dispatch = createEventDispatcher();
|
let dispatch = createEventDispatcher();
|
||||||
|
|
||||||
// file upload
|
// file upload
|
||||||
|
let files: FileList | undefined
|
||||||
/**
|
$: if (files) {
|
||||||
* @type HTMLInputElement
|
[...files].forEach(file=>dispatch("addFiles", file))
|
||||||
*/
|
uploadType = UploadTypes.None
|
||||||
let fileUpload;
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if (fileUpload) {
|
|
||||||
fileUpload.addEventListener("change",() => {
|
|
||||||
dispatch("addFiles",{
|
|
||||||
type: "upload",
|
|
||||||
files: Array.from(fileUpload.files)
|
|
||||||
})
|
|
||||||
uploadType = undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// file clone
|
// file clone
|
||||||
/**
|
let cloneUrlTextbox: HTMLInputElement;
|
||||||
* @type HTMLButtonElement
|
let cloneForm: HTMLFormElement;
|
||||||
*/
|
|
||||||
let cloneButton;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type HTMLInputElement
|
|
||||||
*/
|
|
||||||
let cloneUrlTextbox;
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (cloneButton && cloneUrlTextbox) {
|
if (cloneForm && cloneUrlTextbox) {
|
||||||
cloneButton.addEventListener("click",() => {
|
cloneForm.addEventListener("submit",(e) => {
|
||||||
|
e.preventDefault()
|
||||||
if (cloneUrlTextbox.value) {
|
if (cloneUrlTextbox.value) {
|
||||||
dispatch("addFiles",{
|
dispatch("addFiles",cloneUrlTextbox.value)
|
||||||
type: "clone",
|
uploadType = UploadTypes.None;
|
||||||
url: cloneUrlTextbox.value
|
|
||||||
})
|
|
||||||
uploadType = undefined;
|
|
||||||
} else {
|
} else {
|
||||||
cloneUrlTextbox.animate([
|
cloneUrlTextbox.animate([
|
||||||
{"transform":"translateX(0px)"},
|
{"transform":"translateX(0px)"},
|
||||||
|
@ -68,26 +47,26 @@
|
||||||
|
|
||||||
<div id="add_new_files" transition:_void={{duration:200}}>
|
<div id="add_new_files" transition:_void={{duration:200}}>
|
||||||
<p>
|
<p>
|
||||||
+<span class="_add_files_txt">add files</span>
|
+<span class="add_files_txt">add files</span>
|
||||||
</p>
|
</p>
|
||||||
{#if !uploadType}
|
{#if uploadType == UploadTypes.None}
|
||||||
<div id="file_add_btns" out:_void in:_void={{easingFunc:circOut}}>
|
<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.Files} >upload files...</button>
|
||||||
<button on:click={() => uploadType = uploadTypes.clone} >clone url...</button>
|
<button on:click={() => uploadType = UploadTypes.Clone} >clone url...</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if uploadType == uploadTypes.files}
|
{#if uploadType == UploadTypes.Files}
|
||||||
<div id="file_add_btns" out:_void in:_void={{easingFunc:circOut}}>
|
<div id="file_add_btns" out:_void in:_void={{easingFunc:circOut}}>
|
||||||
<div class="fileUpload">
|
<div class="fileUpload">
|
||||||
<p>click/tap to browse<br/>or drag files into this box</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{:else if uploadType == uploadTypes.clone}
|
{:else if uploadType == UploadTypes.Clone}
|
||||||
<div id="file_add_btns" out:_void in:_void={{easingFunc:circOut}}>
|
<form id="file_add_btns" out:_void in:_void={{easingFunc:circOut}} bind:this={cloneForm}>
|
||||||
<input placeholder="url" type="text" bind:this={cloneUrlTextbox}>
|
<input placeholder="url" type="text" bind:this={cloneUrlTextbox}>
|
||||||
<button style:flex-basis="30%" bind:this={cloneButton}>add file</button>
|
<input type="submit" value="add file" style:flex-basis="30%">
|
||||||
</div>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
1
src/svelte/global.d.ts
vendored
Normal file
1
src/svelte/global.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="svelte" />
|
5
src/svelte/index.ts
Normal file
5
src/svelte/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import App from "./App.svelte"
|
||||||
|
|
||||||
|
new App({
|
||||||
|
target: document.body
|
||||||
|
})
|
18
src/svelte/tsconfig.json
Normal file
18
src/svelte/tsconfig.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
110
tsconfig.json
110
tsconfig.json
|
@ -1,104 +1,10 @@
|
||||||
{
|
{
|
||||||
"include":["src/server/**/*"],
|
"compilerOptions": {
|
||||||
"compilerOptions": {
|
"rootDir": ".",
|
||||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
"outDir": ".",
|
||||||
|
"resolveJsonModule": true,
|
||||||
/* Projects */
|
"composite": true,
|
||||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
"skipLibCheck": true
|
||||||
// "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. */
|
"files": ["package.json"]
|
||||||
// "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. */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
21
vite.config.ts
Normal file
21
vite.config.ts
Normal 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()
|
||||||
|
})],
|
||||||
|
})
|
Loading…
Reference in a new issue