+
+
\ No newline at end of file
diff --git a/pages/index.html b/pages/index.html
new file mode 100644
index 00000000..453434d3
--- /dev/null
+++ b/pages/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ monofile
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/rollup.config.mjs b/rollup.config.mjs
new file mode 100644
index 00000000..b8f64fdf
--- /dev/null
+++ b/rollup.config.mjs
@@ -0,0 +1,17 @@
+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({})
+ ]
+ }
+]
\ No newline at end of file
diff --git a/src/client/index.js b/src/client/index.js
new file mode 100644
index 00000000..f8e4e623
--- /dev/null
+++ b/src/client/index.js
@@ -0,0 +1,5 @@
+import App from "../svelte/App.svelte"
+
+new App({
+ target: document.body
+})
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100644
index 5d32061f..00000000
--- a/src/index.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- i really should split this up into different modules
-*/
-
-import bodyParser from "body-parser"
-import multer, {memoryStorage} from "multer"
-import Discord, { IntentsBitField, Client } from "discord.js"
-import express from "express"
-import fs from "fs"
-import axios, { AxiosResponse } from "axios"
-
-import Files from "./lib/files"
-require("dotenv").config()
-
-const multerSetup = multer({storage:memoryStorage()})
-let pkg = require(`${process.cwd()}/package.json`)
-let app = express()
-let config = require(`${process.cwd()}/config.json`)
-
-app.use("/static",express.static("assets"))
-app.use(bodyParser.text({limit:(config.maxDiscordFileSize*config.maxDiscordFiles)+1048576,type:["application/json","text/plain"]}))
-// funcs
-
-function ThrowError(response:express.Response,code:number,errorMessage:string) {
- fs.readFile(__dirname+"/../pages/error.html",(err,buf) => {
- if (err) {response.sendStatus(500);console.log(err);return}
- response.status(code)
- response.send(buf.toString().replace(/\$ErrorCode/g,code.toString()).replace(/\$ErrorMessage/g,errorMessage).replace(/\$Version/g,pkg.version))
- })
-}
-
-// init data
-
-if (!fs.existsSync(__dirname+"/../.data/")) fs.mkdirSync(__dirname+"/../.data/")
-
-
-
-// discord
-
-let client = new Client({intents:[
- IntentsBitField.Flags.GuildMessages,
- IntentsBitField.Flags.MessageContent
-],rest:{timeout:config.requestTimeout}})
-
-let files = new Files(client,config)
-
-// routes (could probably make these use routers)
-
-// index, clone
-
-app.get("/", function(req,res) {
- fs.readFile(__dirname+"/../pages/base.html",(err,buf) => {
- if (err) {res.sendStatus(500);console.log(err);return}
- res.send(
- buf.toString()
- .replace("$MaxInstanceFilesize",`${(config.maxDiscordFileSize*config.maxDiscordFiles)/1048576}MB`)
- .replace(/\$Version/g,pkg.version)
- .replace(/\$Handler/g,"upload_file")
- .replace(/\$UploadButtonText/g,"Upload file")
- .replace(/\$otherPath/g,"/clone")
- .replace(/\$otherText/g,"clone from url...")
- .replace(/\$FileNum/g,Object.keys(files.files).length.toString())
- )
- })
-})
-
-app.get("/clone", function(req,res) {
- fs.readFile(__dirname+"/../pages/base.html",(err,buf) => {
- if (err) {res.sendStatus(500);console.log(err);return}
- res.send(
- buf.toString()
- .replace("$MaxInstanceFilesize",`${(config.maxDiscordFileSize*config.maxDiscordFiles)/1048576}MB`)
- .replace(/\$Version/g,pkg.version)
- .replace(/\$Handler/g,"clone_file")
- .replace(/\$UploadButtonText/g,"Input a URL")
- .replace(/\$otherPath/g,"/")
- .replace(/\$otherText/g,"upload file...")
- .replace(/\$FileNum/g,Object.keys(files.files).length.toString())
- )
- })
-})
-
-// upload handlers
-
-app.post("/upload",multerSetup.single('file'),async (req,res) => {
- if (req.file) {
- try {
- files.uploadFile({name:req.file.originalname,mime:req.file.mimetype,uploadId:req.header("monofile-upload-id")},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")
- }
-})
-
-app.post("/clone",(req,res) => {
- try {
- let j = JSON.parse(req.body)
- if (!j.url) {
- res.status(400)
- res.send("[err] invalid url")
- }
- axios.get(j.url,{responseType:"arraybuffer"}).then((data:AxiosResponse) => {
- files.uploadFile({name:j.url.split("/")[req.body.split("/").length-1] || "generic",mime:data.headers["content-type"],uploadId:j.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")
- }
-})
-
-// serve files & download page
-
-app.get("/download/:fileId",(req,res) => {
- if (files.getFilePointer(req.params.fileId)) {
- let file = files.getFilePointer(req.params.fileId)
-
- fs.readFile(__dirname+"/../pages/download.html",(err,buf) => {
- 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(/\$FileName/g,
- file.filename
- .replace(/\&/g,"&")
- .replace(/\/g,">")
- )
- )
- })
- } else {
- ThrowError(res,404,"File not found.")
- }
-})
-
-app.get("/file/:fileId",async (req,res) => {
- files.readFileStream(req.params.fileId).then(f => {
- res.setHeader("Content-Type",f.contentType)
- res.status(200)
- f.dataStream.pipe(res)
- }).catch((err) => {
- ThrowError(res,err.status,err.message)
- })
-})
-
-app.get("*",(req,res) => {
- ThrowError(res,404,"Page not found.")
-})
-
-app.get("/server",(req,res) => {
- res.send(JSON.stringify({...config,version:pkg.version}))
-})
-
-// listen on 3000 or MONOFILE_PORT
-
-app.listen(process.env.MONOFILE_PORT || 3000,function() {
- console.log("Web OK!")
-})
-
-client.login(process.env.TOKEN)
\ No newline at end of file
diff --git a/src/lib/files.ts b/src/lib/files.ts
deleted file mode 100644
index 8eb0f429..00000000
--- a/src/lib/files.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-import axios from "axios";
-import Discord, { Client, TextBasedChannel } from "discord.js";
-import { readFile, writeFile } from "fs";
-import { Readable } from "node:stream"
-
-export let id_check_regex = /[A-Za-z0-9_\-\.]+/
-
-
-export interface FileUploadSettings {
- name?: string,
- mime: string,
- uploadId?: string
-}
-
-export interface Configuration {
- maxDiscordFiles: number,
- maxDiscordFileSize: number,
- targetGuild: string,
- targetChannel: string,
- requestTimeout: number
-}
-
-export interface FilePointer {
- filename:string,
- mime:string,
- messageids:string[]
-}
-
-export interface StatusCodeError {
- status: number,
- message: string
-}
-
-/* */
-
-export default class Files {
-
- config: Configuration
- client: Client
- files: {[key:string]:FilePointer} = {}
- uploadChannel?: TextBasedChannel
-
- constructor(client: Client, config: Configuration) {
-
- this.config = config;
- this.client = client;
-
- client.on("ready",() => {
- console.log("Discord OK!")
-
- client.guilds.fetch(config.targetGuild).then((g) => {
- g.channels.fetch(config.targetChannel).then((a) => {
- if (a?.isTextBased()) {
- this.uploadChannel = a
- }
- })
- })
- })
-
- readFile(process.cwd()+"/.data/files.json",(err,buf) => {
- if (err) {console.log(err);return}
- this.files = JSON.parse(buf.toString() || "{}")
- })
-
- }
-
- uploadFile(settings:FileUploadSettings,fBuffer:Buffer):Promise {
- return new Promise(async (resolve,reject) => {
- if (!this.uploadChannel) {
- reject({status:503,message:"server is not ready - please try again later"})
- return
- }
-
- if (!settings.name || !settings.mime) {
- reject({status:400,message:"missing name/mime"});
- return
- }
-
- let uploadId = (settings.uploadId || Math.random().toString().slice(2)).toString();
-
- if ((uploadId.match(id_check_regex) || [])[0] != uploadId || uploadId.length > 30) {
- reject({status:400,message:"invalid id"});return
- }
-
- if (this.files[uploadId]) {
- reject({status:400,message:"a file with this id already exists"});
- return
- }
-
- if (settings.name.length > 128) {
- reject({status:400,message:"name too long"});
- return
- }
-
- if (settings.mime.length > 128) {
- reject({status:400,message:"mime too long"});
- return
- }
-
- // get buffer
- if (fBuffer.byteLength >= (this.config.maxDiscordFileSize*this.config.maxDiscordFiles)) {
- reject({status:400,message:"file too large"});
- return
- }
-
- // generate buffers to upload
- let toUpload = []
- for (let i = 0; i < Math.ceil(fBuffer.byteLength/this.config.maxDiscordFileSize); i++) {
- toUpload.push(
- fBuffer.subarray(
- i*this.config.maxDiscordFileSize,
- Math.min(
- fBuffer.byteLength,
- (i+1)*this.config.maxDiscordFileSize
- )
- )
- )
- }
-
- // begin uploading
- let uploadTmplt:Discord.AttachmentBuilder[] = toUpload.map((e) => {
- return new Discord.AttachmentBuilder(e)
- .setName(Math.random().toString().slice(2))
- })
- let uploadGroups = []
- for (let i = 0; i < Math.ceil(uploadTmplt.length/10); i++) {
- uploadGroups.push(uploadTmplt.slice(i*10,((i+1)*10)))
- }
-
- let msgIds = []
-
- for (let i = 0; i < uploadGroups.length; i++) {
-
- let ms = await this.uploadChannel.send({
- files:uploadGroups[i]
- }).catch((e) => {console.error(e)})
-
- if (ms) {
- msgIds.push(ms.id)
- } else {
- reject({status:500,message:"please try again"}); return
- }
- }
-
- // save
-
- resolve(await this.writeFile(
- uploadId,
- {
- filename:settings.name,
- messageids:msgIds,
- mime:settings.mime
- }
- ))
- })
- }
-
- // fs
-
- writeFile(uploadId: string, file: FilePointer):Promise {
- return new Promise((resolve, reject) => {
-
- this.files[uploadId] = file
-
- writeFile(process.cwd()+"/.data/files.json",JSON.stringify(this.files),(err) => {
-
- if (err) {
- reject({status:500,message:"please try again"});
- delete this.files[uploadId];
- return
- }
-
- resolve(uploadId)
-
- })
-
- })
- }
-
- // todo: move read code here
-
- readFileStream(uploadId: string):Promise<{dataStream:Readable,contentType:string}> {
- return new Promise(async (resolve,reject) => {
- if (!this.uploadChannel) {
- reject({status:503,message:"server is not ready - please try again later"})
- return
- }
-
- if (this.files[uploadId]) {
- let file = this.files[uploadId]
-
- let dataStream = new Readable({
- read(){}
- })
-
- resolve({
- contentType: file.mime,
- dataStream: dataStream
- })
-
- for (let i = 0; i < file.messageids.length; i++) {
- let msg = await this.uploadChannel.messages.fetch(file.messageids[i]).catch(() => {return null})
- if (msg?.attachments) {
- let attach = Array.from(msg.attachments.values())
- for (let i = 0; i < attach.length; i++) {
- let d = await axios.get(attach[i].url,{responseType:"arraybuffer"}).catch((e:Error) => {console.error(e)})
- if (d) {
- dataStream.push(d.data)
- } else {
- reject({status:500,message:"internal server error"})
- dataStream.destroy(new Error("file read error"))
- return
- }
- }
- }
- }
-
- dataStream.push(null)
-
- } else {
- reject({status:404,message:"not found"})
- }
- })
- }
-
- unlink(uploadId:string):Promise {
- return new Promise((resolve,reject) => {
- let tmp = this.files[uploadId];
- delete this.files[uploadId];
- writeFile(process.cwd()+"/.data/files.json",JSON.stringify(this.files),(err) => {
- if (err) {
- this.files[uploadId] = tmp
- reject()
- } else {
- resolve()
- }
- })
-
- })
- }
-
- getFilePointer(uploadId:string):FilePointer {
- return this.files[uploadId]
- }
-
-}
diff --git a/src/server/index.ts b/src/server/index.ts
new file mode 100644
index 00000000..3abd2776
--- /dev/null
+++ b/src/server/index.ts
@@ -0,0 +1,164 @@
+import cookieParser from "cookie-parser";
+import { IntentsBitField, Client } from "discord.js"
+import express from "express"
+import fs from "fs"
+import bytes from "bytes";
+
+import ServeError from "./lib/errors"
+import Files from "./lib/files"
+import * as auth from "./lib/auth"
+import * as Accounts from "./lib/accounts"
+
+import * as authRoutes from "./routes/authRoutes";
+import * as fileApiRoutes from "./routes/fileApiRoutes";
+import * as adminRoutes from "./routes/adminRoutes";
+import * as primaryApi from "./routes/primaryApi";
+
+require("dotenv").config()
+
+let pkg = require(`${process.cwd()}/package.json`)
+let app = express()
+let config = require(`${process.cwd()}/config.json`)
+
+app.use("/static/assets",express.static("assets"))
+app.use("/static/style",express.static("out/style"))
+app.use("/static/js",express.static("out/client"))
+
+//app.use(bodyParser.text({limit:(config.maxDiscordFileSize*config.maxDiscordFiles)+1048576,type:["application/json","text/plain"]}))
+
+app.use(cookieParser())
+
+app.get("/server",(req,res) => {
+ res.send(JSON.stringify({
+ ...config,
+ version:pkg.version,
+ files:Object.keys(files.files).length
+ }))
+})
+
+app
+ .use("/auth",authRoutes.authRoutes)
+ .use("/admin",adminRoutes.adminRoutes)
+ .use("/files", fileApiRoutes.fileApiRoutes)
+ .use(primaryApi.primaryApi)
+// funcs
+
+// init data
+
+if (!fs.existsSync(__dirname+"/../.data/")) fs.mkdirSync(__dirname+"/../.data/")
+
+
+
+// discord
+
+let client = new Client({intents:[
+ IntentsBitField.Flags.GuildMessages,
+ IntentsBitField.Flags.MessageContent
+],rest:{timeout:config.requestTimeout}})
+
+let files = new Files(client,config)
+
+authRoutes.setFilesObj(files)
+adminRoutes.setFilesObj(files)
+fileApiRoutes.setFilesObj(files)
+primaryApi.setFilesObj(files)
+
+// routes (could probably make these use routers)
+
+// index, clone
+
+app.get("/", function(req,res) {
+ res.sendFile(process.cwd()+"/pages/index.html")
+})
+
+// serve download page
+
+app.get("/download/:fileId",(req,res) => {
+ if (files.getFilePointer(req.params.fileId)) {
+ let file = files.getFilePointer(req.params.fileId)
+
+ if (file.visibility == "private" && Accounts.getFromToken(req.cookies.auth)?.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(/\<\!\-\-metaTags\-\-\>/g,
+ (
+ file.mime.startsWith("image/")
+ ? ``
+ : (
+ file.mime.startsWith("video/")
+ ? (
+ `
+
+
+
+ `
+ // quick lazy fix as a fallback
+ // maybe i'll improve this later, but probably not.
+ + ((file.sizeInBytes||0) >= 26214400 ? `
+
+ ` : "")
+ )
+ : ""
+ )
+ )
+ + (
+ fileOwner?.embed?.largeImage && file.visibility!="anonymous"
+ ? ``
+ : ""
+ )
+ + `\n`
+ )
+ .replace(/\<\!\-\-preview\-\-\>/g,
+ file.mime.startsWith("image/")
+ ? ``
+ : (
+ file.mime.startsWith("video/")
+ ? ``
+ : (
+ file.mime.startsWith("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:
+
+ index
+ api
+ dl pages
+ file serving
+*/
+
+// listen on 3000 or MONOFILE_PORT
+
+app.listen(process.env.MONOFILE_PORT || 3000,function() {
+ console.log("Web OK!")
+})
+
+client.login(process.env.TOKEN)
\ No newline at end of file
diff --git a/src/server/lib/accounts.ts b/src/server/lib/accounts.ts
new file mode 100644
index 00000000..270ed187
--- /dev/null
+++ b/src/server/lib/accounts.ts
@@ -0,0 +1,132 @@
+import crypto from "crypto"
+import * as auth from "./auth";
+import { readFile, writeFile } from "fs/promises"
+import { FileVisibility } from "./files";
+
+// this is probably horrible
+// but i don't even care anymore
+
+export let Accounts: Account[] = []
+
+export interface Account {
+ id : string
+ username : string
+ email? : string
+ password : {
+ hash : string
+ salt : string
+ }
+ files : string[]
+ admin : boolean
+ defaultFileVisibility : FileVisibility
+ customCSS? : string
+
+ embed? : {
+ color? : string
+ largeImage? : boolean
+ }
+}
+
+export function create(username:string,pwd:string,admin:boolean=false):Promise {
+ return new Promise((resolve,reject) => {
+ let accId = crypto.randomBytes(12).toString("hex")
+
+ Accounts.push(
+ {
+ id: accId,
+ username: username,
+ password: password.hash(pwd),
+ files: [],
+ admin: admin,
+ defaultFileVisibility: "public"
+ }
+ )
+
+ save().then(() => resolve(accId))
+ })
+}
+
+export function getFromUsername(username:string) {
+ return Accounts.find(e => e.username == username)
+}
+
+export function getFromId(id:string) {
+ return Accounts.find(e => e.id == id)
+}
+
+export function getFromToken(token:string) {
+ let accId = auth.validate(token)
+ if (!accId) return
+ return getFromId(accId)
+}
+
+export function deleteAccount(id:string) {
+ Accounts.splice(Accounts.findIndex(e => e.id == id),1)
+ return save()
+}
+
+export namespace password {
+ export function hash(password:string,_salt?:string) {
+ let salt = _salt || crypto.randomBytes(12).toString('base64')
+ let hash = crypto.createHash('sha256').update(`${salt}${password}`).digest('hex')
+
+ return {
+ salt:salt,
+ hash:hash
+ }
+ }
+
+ export function set(id:string,password:string) {
+ let acc = Accounts.find(e => e.id == id)
+ if (!acc) return
+
+ acc.password = hash(password)
+ return save()
+ }
+
+ export function check(id:string,password:string) {
+ let acc = Accounts.find(e => e.id == id)
+ if (!acc) return
+
+ return acc.password.hash == hash(password,acc.password.salt).hash
+ }
+}
+
+export namespace files {
+ export function index(accountId:string,fileId:string) {
+ // maybe replace with a obj like
+ // { x:true }
+ // for faster lookups? not sure if it would be faster
+ let acc = Accounts.find(e => e.id == accountId)
+ if (!acc) return
+ if (acc.files.find(e => e == fileId)) return
+
+ acc.files.push(fileId)
+ return save()
+ }
+
+ export function deindex(accountId:string,fileId:string, noWrite:boolean=false) {
+ let acc = Accounts.find(e => e.id == accountId)
+ if (!acc) return
+ let fi = acc.files.findIndex(e => e == fileId)
+ if (fi) {
+ acc.files.splice(fi,1)
+ if (!noWrite) return save()
+ }
+ }
+}
+
+export function save() {
+ return writeFile(`${process.cwd()}/.data/accounts.json`,JSON.stringify(Accounts))
+ .catch((err) => console.error(err))
+}
+
+readFile(`${process.cwd()}/.data/accounts.json`)
+ .then((buf) => {
+ Accounts = JSON.parse(buf.toString())
+ }).catch(err => console.error(err))
+ .finally(() => {
+ if (!Accounts.find(e => e.admin)) {
+ create("admin","admin",true)
+ }
+ })
\ No newline at end of file
diff --git a/src/server/lib/auth.ts b/src/server/lib/auth.ts
new file mode 100644
index 00000000..e477cba7
--- /dev/null
+++ b/src/server/lib/auth.ts
@@ -0,0 +1,58 @@
+import crypto from "crypto"
+import { readFile, writeFile } from "fs/promises"
+export let AuthTokens: AuthToken[] = []
+export let AuthTokenTO:{[key:string]:NodeJS.Timeout} = {}
+
+export interface AuthToken {
+ account: string,
+ token: string,
+ expire: number
+}
+
+export function create(id:string,expire:number=(24*60*60*1000)) {
+ let token = {
+ account:id,
+ token:crypto.randomBytes(12).toString('hex'),
+ expire:Date.now()+expire
+ }
+
+ AuthTokens.push(token)
+ tokenTimer(token)
+
+ save()
+
+ return token.token
+}
+
+export function validate(token:string) {
+ return AuthTokens.find(e => e.token == token && Date.now() < e.expire)?.account
+}
+
+export function tokenTimer(token:AuthToken) {
+ if (Date.now() >= token.expire) {
+ invalidate(token.token)
+ return
+ }
+
+ AuthTokenTO[token.token] = setTimeout(() => invalidate(token.token),token.expire-Date.now())
+}
+
+export function invalidate(token:string) {
+ if (AuthTokenTO[token]) {
+ clearTimeout(AuthTokenTO[token])
+ }
+
+ AuthTokens.splice(AuthTokens.findIndex(e => e.token == token),1)
+ save()
+}
+
+export function save() {
+ writeFile(`${process.cwd()}/.data/tokens.json`,JSON.stringify(AuthTokens))
+ .catch((err) => console.error(err))
+}
+
+readFile(`${process.cwd()}/.data/tokens.json`)
+ .then((buf) => {
+ AuthTokens = JSON.parse(buf.toString())
+ AuthTokens.forEach(e => tokenTimer(e))
+ }).catch(err => console.error(err))
\ No newline at end of file
diff --git a/src/server/lib/errors.ts b/src/server/lib/errors.ts
new file mode 100644
index 00000000..6e67f33c
--- /dev/null
+++ b/src/server/lib/errors.ts
@@ -0,0 +1,37 @@
+import { Response } from "express";
+import { readFile } from "fs/promises"
+
+let errorPage:string
+
+export default async function ServeError(
+ res:Response,
+ code:number,
+ reason:string
+) {
+ // fetch error page if not cached
+ if (!errorPage) {
+ errorPage =
+ (
+ await readFile(`${process.cwd()}/pages/error.html`)
+ .catch((err) => console.error(err))
+ || "
$code $text
"
+ )
+ .toString()
+ }
+
+ // serve error
+ res.statusMessage = reason
+ res.status(code)
+ res.header("x-backup-status-message", reason) // glitch default nginx configuration
+ res.send(
+ errorPage
+ .replace(/\$code/g,code.toString())
+ .replace(/\$text/g,reason)
+ )
+}
+
+export function Redirect(res:Response,url:string) {
+ res.status(302)
+ res.header("Location",url)
+ res.send()
+}
\ No newline at end of file
diff --git a/src/server/lib/files.ts b/src/server/lib/files.ts
new file mode 100644
index 00000000..40fd33dc
--- /dev/null
+++ b/src/server/lib/files.ts
@@ -0,0 +1,437 @@
+import axios from "axios";
+import Discord, { Client, TextBasedChannel } from "discord.js";
+import { readFile, writeFile } from "fs";
+import { Readable } from "node:stream";
+import { files } from "./accounts";
+
+import * as Accounts from "./accounts";
+
+export let id_check_regex = /[A-Za-z0-9_\-\.\!]+/
+export let alphanum = Array.from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890")
+
+// bad solution but whatever
+
+export type FileVisibility = "public" | "anonymous" | "private"
+
+export function generateFileId(length:number=5) {
+ let fid = ""
+ for (let i = 0; i < length; i++) {
+ fid += alphanum[Math.floor(Math.random()*alphanum.length)]
+ }
+ return fid
+}
+
+export interface FileUploadSettings {
+ name?: string,
+ mime: string,
+ uploadId?: string,
+ owner?:string
+}
+
+export interface Configuration {
+ maxDiscordFiles: number,
+ maxDiscordFileSize: number,
+ targetGuild: string,
+ targetChannel: string,
+ requestTimeout: number,
+ maxUploadIdLength: number,
+
+ accounts: {
+ registrationEnabled: boolean,
+ requiredForUpload: boolean
+ }
+}
+
+export interface FilePointer {
+ filename:string,
+ mime:string,
+ messageids:string[],
+ owner?:string,
+ sizeInBytes?:number,
+ tag?:string,
+ visibility?:FileVisibility,
+ reserved?: boolean,
+ chunkSize?: number
+}
+
+export interface StatusCodeError {
+ status: number,
+ message: string
+}
+
+/* */
+
+export default class Files {
+
+ config: Configuration
+ client: Client
+ files: {[key:string]:FilePointer} = {}
+ uploadChannel?: TextBasedChannel
+
+ constructor(client: Client, config: Configuration) {
+
+ this.config = config;
+ this.client = client;
+
+ client.on("ready",() => {
+ console.log("Discord OK!")
+
+ client.guilds.fetch(config.targetGuild).then((g) => {
+ g.channels.fetch(config.targetChannel).then((a) => {
+ if (a?.isTextBased()) {
+ this.uploadChannel = a
+ }
+ })
+ })
+ })
+
+ readFile(process.cwd()+"/.data/files.json",(err,buf) => {
+ if (err) {console.log(err);return}
+ this.files = JSON.parse(buf.toString() || "{}")
+ })
+
+ }
+
+ uploadFile(settings:FileUploadSettings,fBuffer:Buffer):Promise {
+ return new Promise(async (resolve,reject) => {
+ if (!this.uploadChannel) {
+ reject({status:503,message:"server is not ready - please try again later"})
+ return
+ }
+
+ if (!settings.name || !settings.mime) {
+ reject({status:400,message:"missing name/mime"});
+ return
+ }
+
+ if (!settings.owner && this.config.accounts.requiredForUpload) {
+ reject({status:401,message:"an account is required for upload"});
+ return
+ }
+
+ let uploadId = (settings.uploadId || generateFileId()).toString();
+
+ if ((uploadId.match(id_check_regex) || [])[0] != uploadId || uploadId.length > this.config.maxUploadIdLength) {
+ reject({status:400,message:"invalid id"});return
+ }
+
+ if (this.files[uploadId] && (settings.owner ? this.files[uploadId].owner != settings.owner : true)) {
+ reject({status:400,message:"you are not the owner of this file id"});
+ return
+ }
+
+ if (this.files[uploadId] && this.files[uploadId].reserved) {
+ reject({status:400,message:"already uploading this file. if your file is stuck in this state, contact an administrator"});
+ return
+ }
+
+ if (settings.name.length > 128) {
+ reject({status:400,message:"name too long"});
+ return
+ }
+
+ if (settings.mime.length > 128) {
+ reject({status:400,message:"mime too long"});
+ return
+ }
+
+ // reserve file, hopefully should prevent
+ // large files breaking
+
+ let ogf = this.files[uploadId]
+
+ this.files[uploadId] = {
+ filename:settings.name,
+ messageids:[],
+ mime:settings.mime,
+ sizeInBytes:0,
+
+ owner:settings.owner,
+ visibility: settings.owner ? "private" : "public",
+ reserved: true,
+
+ chunkSize: this.config.maxDiscordFileSize
+ }
+
+ // save
+
+ if (settings.owner) {
+ await files.index(settings.owner,uploadId)
+ }
+
+ // get buffer
+ if (fBuffer.byteLength >= (this.config.maxDiscordFileSize*this.config.maxDiscordFiles)) {
+ reject({status:400,message:"file too large"});
+ return
+ }
+
+ // generate buffers to upload
+ let toUpload = []
+ for (let i = 0; i < Math.ceil(fBuffer.byteLength/this.config.maxDiscordFileSize); i++) {
+ toUpload.push(
+ fBuffer.subarray(
+ i*this.config.maxDiscordFileSize,
+ Math.min(
+ fBuffer.byteLength,
+ (i+1)*this.config.maxDiscordFileSize
+ )
+ )
+ )
+ }
+
+ // begin uploading
+ let uploadTmplt:Discord.AttachmentBuilder[] = toUpload.map((e) => {
+ return new Discord.AttachmentBuilder(e)
+ .setName(Math.random().toString().slice(2))
+ })
+ let uploadGroups = []
+ for (let i = 0; i < Math.ceil(uploadTmplt.length/10); i++) {
+ uploadGroups.push(uploadTmplt.slice(i*10,((i+1)*10)))
+ }
+
+ let msgIds = []
+
+ for (let i = 0; i < uploadGroups.length; i++) {
+
+ let ms = await this.uploadChannel.send({
+ files:uploadGroups[i]
+ }).catch((e) => {console.error(e)})
+
+ if (ms) {
+ msgIds.push(ms.id)
+ } else {
+ if (!ogf) delete this.files[uploadId]
+ else this.files[uploadId] = ogf
+ reject({status:500,message:"please try again"}); return
+ }
+ }
+
+ // this code deletes the files from discord, btw
+ // if need be, replace with job queue system
+
+ if (ogf&&this.uploadChannel) {
+ for (let x of ogf.messageids) {
+ this.uploadChannel.messages.delete(x).catch(err => console.error(err))
+ }
+ }
+
+ resolve(await this.writeFile(
+ uploadId,
+ {
+ filename:settings.name,
+ messageids:msgIds,
+ mime:settings.mime,
+ sizeInBytes:fBuffer.byteLength,
+
+ owner:settings.owner,
+ visibility: ogf ? ogf.visibility
+ : (
+ settings.owner
+ ? Accounts.getFromId(settings.owner)?.defaultFileVisibility
+ : undefined
+ ),
+ // so that json.stringify doesnt include tag:undefined
+ ...((ogf||{}).tag ? {tag:ogf.tag} : {}),
+
+ chunkSize: this.config.maxDiscordFileSize
+ }
+ ))
+
+
+ })
+ }
+
+ // fs
+
+ writeFile(uploadId: string, file: FilePointer):Promise {
+ return new Promise((resolve, reject) => {
+
+ this.files[uploadId] = file
+
+ writeFile(process.cwd()+"/.data/files.json",JSON.stringify(this.files),(err) => {
+
+ if (err) {
+ reject({status:500,message:"server may be misconfigured, contact admin for help"});
+ delete this.files[uploadId];
+ return
+ }
+
+ resolve(uploadId)
+
+ })
+
+ })
+ }
+
+ // todo: move read code here
+
+ readFileStream(uploadId: string, range?: {start:number, end:number}):Promise {
+ return new Promise(async (resolve,reject) => {
+ if (!this.uploadChannel) {
+ reject({status:503,message:"server is not ready - please try again later"})
+ return
+ }
+
+ if (this.files[uploadId]) {
+ let file = this.files[uploadId]
+
+ let
+ scan_msg_begin = 0,
+ scan_msg_end = file.messageids.length-1,
+ scan_files_begin = 0,
+ scan_files_end = -1
+
+ let useRanges = range && file.chunkSize && file.sizeInBytes;
+
+ // todo: figure out how to get typesccript to accept useRanges
+ // i'm too tired to look it up or write whatever it wnats me to do
+ if (range && file.chunkSize && file.sizeInBytes) {
+
+ // Calculate where to start file scans...
+
+ scan_files_begin = Math.floor(range.start / file.chunkSize)
+ scan_files_end = Math.ceil(range.end / file.chunkSize) - 1
+
+ scan_msg_begin = Math.floor(scan_files_begin / 10)
+ scan_msg_end = Math.ceil(scan_files_end / 10)
+
+ }
+
+ let attachments: Discord.Attachment[] = [];
+
+ /* File updates */
+ let file_updates: Pick = {}
+ let atSIB: number[] = [] // kepes track of the size of each file...
+
+ for (let xi = scan_msg_begin; xi < scan_msg_end+1; xi++) {
+
+ let msg = await this.uploadChannel.messages.fetch(file.messageids[xi]).catch(() => {return null})
+ if (msg?.attachments) {
+
+ let attach = Array.from(msg.attachments.values())
+ for (let i = (useRanges && xi == scan_msg_begin ? ( scan_files_begin - (xi*10) ) : 0); i < (useRanges && xi == scan_msg_end ? ( scan_files_end - (xi*10) + 1 ) : attach.length); i++) {
+
+ attachments.push(attach[i])
+ atSIB.push(attach[i].size)
+
+ }
+
+ }
+
+ }
+
+ if (!file.sizeInBytes) file_updates.sizeInBytes = atSIB.reduce((a,b) => a+b);
+ if (!file.chunkSize) file_updates.chunkSize = atSIB[0]
+ if (Object.keys(file_updates).length) { // if file_updates not empty
+ // i gotta do these weird workarounds, ts is weird sometimes
+ // originally i was gonna do key is keyof FilePointer but for some reason
+ // it ended up making typeof file[key] never??? so
+ // its 10pm and chinese people suck at being quiet so i just wanna get this over with
+ // chinese is the worst language in terms of volume lmao
+ let valid_fp_keys = ["sizeInBytes", "chunkSize"]
+ let isValidFilePointerKey = (key: string): key is "sizeInBytes" | "chunkSize" => valid_fp_keys.includes(key)
+
+ for (let [key,value] of Object.entries(file_updates)) {
+ if (isValidFilePointerKey(key)) file[key] = value
+ }
+
+ writeFile(process.cwd()+"/.data/files.json",JSON.stringify(this.files),(err) => {})
+ }
+
+ let position = 0;
+
+ let getNextChunk = async () => {
+ let scanning_chunk = attachments[position]
+ if (!scanning_chunk) {
+ return null
+ }
+
+ let d = await axios.get(
+ scanning_chunk.url,
+ {
+ responseType:"arraybuffer",
+ headers: {
+ ...(useRanges ? {
+ "Range": `bytes=${position == 0 && range && file.chunkSize ? range.start-(scan_files_begin*file.chunkSize) : "0"}-${position == attachments.length-1 && range && file.chunkSize ? range.end-(scan_files_end*file.chunkSize) : ""}`
+ } : {})
+ }
+ }
+ ).catch((e:Error) => {console.error(e)})
+
+ position++;
+
+ if (d) {
+ return d.data
+ } else {
+ reject({status:500,message:"internal server error"})
+ return "__ERR"
+ }
+ }
+
+ let ord:number[] = []
+ // hopefully this regulates it?
+ let lastChunkSent = true
+
+ let dataStream = new Readable({
+ read(){
+ if (!lastChunkSent) return
+ lastChunkSent = false
+ getNextChunk().then(async (nextChunk) => {
+ if (nextChunk == "__ERR") {this.destroy(new Error("file read error")); return}
+ let response = this.push(nextChunk)
+
+ if (!nextChunk) return // EOF
+
+ while (response) {
+ let nextChunk = await getNextChunk()
+ response = this.push(nextChunk)
+ if (!nextChunk) return
+ }
+ lastChunkSent = true
+ })
+ }
+ })
+
+ resolve(dataStream)
+
+ } else {
+ reject({status:404,message:"not found"})
+ }
+ })
+ }
+
+ unlink(uploadId:string, noWrite: boolean = false):Promise {
+ return new Promise(async (resolve,reject) => {
+ let tmp = this.files[uploadId];
+ if (!tmp) {resolve(); return}
+ if (tmp.owner) {
+ let id = files.deindex(tmp.owner,uploadId,noWrite);
+ if (id) await id
+ }
+ // this code deletes the files from discord, btw
+ // if need be, replace with job queue system
+
+ if (!this.uploadChannel) {reject(); return}
+ for (let x of tmp.messageids) {
+ this.uploadChannel.messages.delete(x).catch(err => console.error(err))
+ }
+
+ delete this.files[uploadId];
+ if (noWrite) {resolve(); return}
+ writeFile(process.cwd()+"/.data/files.json",JSON.stringify(this.files),(err) => {
+ if (err) {
+ this.files[uploadId] = tmp // !! this may not work, since tmp is a link to this.files[uploadId]?
+ reject()
+ } else {
+ resolve()
+ }
+ })
+
+ })
+ }
+
+ getFilePointer(uploadId:string):FilePointer {
+ return this.files[uploadId]
+ }
+
+}
diff --git a/src/server/lib/mail.ts b/src/server/lib/mail.ts
new file mode 100644
index 00000000..4fdbb3e0
--- /dev/null
+++ b/src/server/lib/mail.ts
@@ -0,0 +1,38 @@
+import { createTransport } from "nodemailer";
+
+// required i guess
+require("dotenv").config()
+
+let
+mailConfig =
+ require( process.cwd() + "/config.json" ).mail,
+transport =
+ createTransport(
+ {
+ ...mailConfig.transport,
+ auth: {
+ user: process.env.MAIL_USER,
+ pass: process.env.MAIL_PASS
+ }
+ }
+ )
+
+// lazy but
+
+export function sendMail(to: string, subject: string, content: string) {
+ return new Promise((resolve,reject) => {
+ transport.sendMail({
+ to,
+ subject,
+ "from": mailConfig.send.from,
+ "html": `monofile accounts Gain control of your uploads. ${
+ content
+ .replace(/\/g, `@`)
+ .replace(/\/g,``)
+ }
If you do not believe that you are the intended recipient of this email, please disregard this message.`
+ }, (err, info) => {
+ if (err) reject(err)
+ else resolve(info)
+ })
+ })
+}
\ No newline at end of file
diff --git a/src/server/lib/middleware.ts b/src/server/lib/middleware.ts
new file mode 100644
index 00000000..a56ba95a
--- /dev/null
+++ b/src/server/lib/middleware.ts
@@ -0,0 +1,24 @@
+import * as Accounts from "./accounts";
+import express, { type RequestHandler } from "express"
+import ServeError from "../lib/errors";
+
+export let getAccount: RequestHandler = function(req, res, next) {
+ res.locals.acc = Accounts.getFromToken(req.cookies.auth)
+ next()
+}
+
+export let requiresAccount: RequestHandler = function(_req, res, next) {
+ if (!res.locals.acc) {
+ ServeError(res, 401, "not logged in")
+ return
+ }
+ next()
+}
+
+export let requiresAdmin: RequestHandler = function(_req, res, next) {
+ if (!res.locals.acc.admin) {
+ ServeError(res, 403, "you are not an administrator")
+ return
+ }
+ next()
+}
\ No newline at end of file
diff --git a/src/server/lib/ratelimit.ts b/src/server/lib/ratelimit.ts
new file mode 100644
index 00000000..a53533aa
--- /dev/null
+++ b/src/server/lib/ratelimit.ts
@@ -0,0 +1,45 @@
+import { RequestHandler } from "express"
+import { type Account } from "./accounts"
+import ServeError from "./errors"
+
+interface ratelimitSettings {
+
+ requests: number
+ per: number
+
+}
+
+export function accountRatelimit( settings: ratelimitSettings ): RequestHandler {
+ let activeLimits: {
+ [ key: string ]: {
+ requests: number,
+ expirationHold: NodeJS.Timeout
+ }
+ } = {}
+
+ return (req, res, next) => {
+ if (res.locals.acc) {
+ let accId = res.locals.acc.id
+ let aL = activeLimits[accId]
+
+ if (!aL) {
+ activeLimits[accId] = {
+ requests: 0,
+ expirationHold: setTimeout(() => delete activeLimits[accId], settings.per)
+ }
+ aL = activeLimits[accId]
+ }
+
+ if (aL.requests < settings.requests) {
+ res.locals.undoCount = () => {
+ if (activeLimits[accId]) {
+ activeLimits[accId].requests--
+ }
+ }
+ next()
+ } else {
+ ServeError(res, 429, "too many requests")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/server/routes/adminRoutes.ts b/src/server/routes/adminRoutes.ts
new file mode 100644
index 00000000..7b24baa1
--- /dev/null
+++ b/src/server/routes/adminRoutes.ts
@@ -0,0 +1,195 @@
+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 } 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)
+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`, `Hello there! This email is to notify you of a password change that an administrator, ${acc.username}, 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) => {
+
+ let acc = res.locals.acc as Accounts.Account
+
+ 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, ${targetAccount.username}, has been deleted by ${acc.username} for the following reason:
${req.body.reason || "(no reason specified)"}
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) => {
+
+ let acc = res.locals.acc as Accounts.Account
+
+ 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
+
+})
\ No newline at end of file
diff --git a/src/server/routes/authRoutes.ts b/src/server/routes/authRoutes.ts
new file mode 100644
index 00000000..39e84e48
--- /dev/null
+++ b/src/server/routes/authRoutes.ts
@@ -0,0 +1,461 @@
+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, requiresAccount } 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, 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, 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, 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)
+ && 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, 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, 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, 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`, `Hello there! Your username has been updated to ${req.body.username}. 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()
+
+authRoutes.post("/request_email_change", requiresAccount, 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`, `Hello there! You are recieving this message because you decided to link your email, ${req.body.email.split("@")[0]}@${req.body.email.split("@")[1]}, to your account, ${acc.username}. If you would like to continue, please click here, 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, (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")
+ }
+})
+
+let pwReset = new Map()
+let prcIdx = new Map()
+
+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}`, `Hello there! You are recieving this message because you forgot your password to your monofile account, ${acc.username}. To log in, please click here, 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, 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`, `Hello there! 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, (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, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ let accId = acc.id
+ res.send({
+ ...acc,
+ sessionCount: auth.AuthTokens.filter(e => e.account == accId && e.expire > Date.now()).length,
+ sessionExpires: auth.AuthTokens.find(e => e.token == req.cookies.auth)?.expire,
+ password: undefined
+ })
+})
+
+authRoutes.get("/customCSS", (req,res) => {
+ if (!auth.validate(req.cookies.auth)) {
+ ServeError(res, 401, "not logged in")
+ return
+ }
+
+ // lazy rn so
+
+ let acc = Accounts.getFromToken(req.cookies.auth)
+ if (acc) {
+ if (acc.customCSS) {
+ res.redirect(`/file/${acc.customCSS}`)
+ } else {
+ res.send("")
+ }
+ } else res.send("")
+})
\ No newline at end of file
diff --git a/src/server/routes/fileApiRoutes.ts b/src/server/routes/fileApiRoutes.ts
new file mode 100644
index 00000000..be461c9b
--- /dev/null
+++ b/src/server/routes/fileApiRoutes.ts
@@ -0,0 +1,104 @@
+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";
+
+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.get("/list", (req,res) => {
+
+ if (!auth.validate(req.cookies.auth)) {
+ ServeError(res, 401, "not logged in")
+ return
+ }
+
+ let acc = Accounts.getFromToken(req.cookies.auth)
+
+ 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, (req,res) => {
+
+ if (!auth.validate(req.cookies.auth)) {
+ ServeError(res, 401, "not logged in")
+ return
+ }
+
+ let acc = Accounts.getFromToken(req.cookies.auth) 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))
+
+
+})
\ No newline at end of file
diff --git a/src/server/routes/primaryApi.ts b/src/server/routes/primaryApi.ts
new file mode 100644
index 00000000..5ce01bb0
--- /dev/null
+++ b/src/server/routes/primaryApi.ts
@@ -0,0 +1,171 @@
+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";
+
+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.get(["/file/:fileId", "/cpt/:fileId/*", "/:fileId"], async (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) {
+
+ if (file.visibility == "private" && Accounts.getFromToken(req.cookies.auth)?.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",multerSetup.single('file'),async (req,res) => {
+ if (req.file) {
+ try {
+ let prm = req.header("monofile-params")
+ let params:{[key:string]:any} = {}
+ if (prm) {
+ params = JSON.parse(prm)
+ }
+
+ files.uploadFile({
+ owner: auth.validate(req.cookies.auth),
+
+ 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", bodyParser.json({type: ["text/plain","application/json"]}) ,(req,res) => {
+ try {
+ axios.get(req.body.url,{responseType:"arraybuffer"}).then((data:AxiosResponse) => {
+
+ files.uploadFile({
+ owner: auth.validate(req.cookies.auth),
+
+ 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")
+ }
+})
\ No newline at end of file
diff --git a/src/style/_base.scss b/src/style/_base.scss
new file mode 100644
index 00000000..b3546650
--- /dev/null
+++ b/src/style/_base.scss
@@ -0,0 +1,85 @@
+/*
+ could probably replace this with fonts served directly
+ from the server but it's fine for now
+*/
+
+@import url("/static/assets/fonts/inconsolata.css");
+@import url("/static/assets/fonts/source_sans.css");
+@import url("/static/assets/fonts/fira_code.css");
+
+$FallbackFonts:
+ -apple-system,
+ system-ui,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+
+%normal {
+ font-family: "Source Sans Pro", $FallbackFonts
+}
+
+/*
+ everything that's not a span
+ and/or has the normal class
+ (it's just in case)
+*/
+
+*:not(span), .normal { @extend %normal; }
+
+/*
+ for code blocks / terminal
+*/
+
+.monospace {
+ font-family: "Fira Code", monospace
+}
+
+/*
+ colors
+*/
+
+$Background: #252525;
+/* hsl(210,12.9,24.3) */
+$darkish: rgb(54, 62, 70);
+
+/*
+ then other stuff
+*/
+
+body {
+ background-color: rgb(30, 33, 36); // this is here so that
+ // pulling down to refresh
+ // on mobile looks good
+}
+
+#appContent {
+ background-color: $Background
+}
+
+/*
+ scrollbars
+*/
+
+* {
+ /* nice scrollbars aren't needed on mobile so */
+ @media screen and (min-width:500px) {
+
+ &::-webkit-scrollbar {
+ width:5px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background-color:#191919;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background-color:#333;
+
+ &:hover {
+ background-color:#373737;
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/src/style/app.scss b/src/style/app.scss
new file mode 100644
index 00000000..886c7927
--- /dev/null
+++ b/src/style/app.scss
@@ -0,0 +1,41 @@
+@use "base";
+@use "app/topbar";
+@use "app/pulldown";
+@use "app/uploads";
+
+.menuBtn {
+ text-decoration:none;
+ font-size:16px;
+ transition-duration: 100ms;
+
+ color:#555555;
+ background-color: #00000000;
+ border:none;
+ margin:0 0 0 0;
+ cursor:pointer;
+
+ position:relative;
+ top:-1px;
+
+ &:hover {
+ color:slategray;
+ transition-duration: 100ms;
+ }
+}
+
+#appContent {
+ position:absolute;
+ left:0px;
+ top:40px;
+ width:100%;
+ height: calc( 100% - 40px );
+ background-image: linear-gradient(#333,base.$Background);
+
+ @media screen and (max-width:500px) {
+ background-image: linear-gradient(#303030,base.$Background);
+ }
+}
+
+.number {
+ font-family: "Inconsolata", monospace;
+}
\ No newline at end of file
diff --git a/src/style/app/pulldown.scss b/src/style/app/pulldown.scss
new file mode 100644
index 00000000..9d37ecc5
--- /dev/null
+++ b/src/style/app/pulldown.scss
@@ -0,0 +1,49 @@
+@use "../base";
+@use "pulldown/help";
+@use "pulldown/accounts";
+@use "pulldown/files";
+@use "pulldown/modals";
+
+#overlay, .modalContainer {
+ position:absolute;
+ left:0px;
+ height: 100%;
+ width:100%;
+ top:0px;
+ border:none;
+ outline:none;
+ background-color:rgba(170, 170, 170, 0.25);
+
+ z-index: 1000;
+}
+
+.pulldown {
+ position: absolute;
+ width: 300px;
+ height: 400px;
+ background-color: #191919;
+ color: #dddddd;
+
+ top:0px;
+ left:50%;
+ transform:translateX(-50%);
+
+ @media screen and (max-width: 500px) {
+ width: 100%;
+ height: 100%;
+ }
+
+ p, h1, h2 {
+ margin:0px;
+ }
+
+ z-index: 1001;
+}
+
+.pulldown_display {
+ position:absolute;
+ left:0px;
+ top:0px;
+ width:100%;
+ height:100%;
+}
\ No newline at end of file
diff --git a/src/style/app/pulldown/accounts.scss b/src/style/app/pulldown/accounts.scss
new file mode 100644
index 00000000..b6fe02b0
--- /dev/null
+++ b/src/style/app/pulldown/accounts.scss
@@ -0,0 +1,187 @@
+.pulldown_display[name=accounts] {
+ .notLoggedIn {
+ .container_div {
+ position:absolute;
+ top:50%;
+ transform:translateY(-50%);
+ width:100%;
+ text-align:center;
+
+ h1 {
+ font-weight:600;
+ font-size:24px;
+
+ @media screen and (max-width:500px) {
+ font-size:30px;
+ }
+ }
+
+ .flavor {
+ font-size:14px;
+
+ /* good enoough */
+
+ @media screen and (max-width:500px) {
+ font-size:16px;
+ }
+
+ color:#999999;
+ margin: 0 0 10px 0;
+ }
+
+ button {
+ cursor:pointer;
+ background-color:#393939;
+ color:#DDDDDD;
+ border:none;
+ outline:none;
+ padding:5px;
+ transition-duration: 250ms;
+ /*overflow:clip;*/
+
+ @media screen and (max-width: 500px) {
+ font-size:16px;
+ padding:10px;
+ }
+
+ &:hover {
+ transition-duration: 250ms;
+ background-color:#434343;
+ color: #ffffff;
+ }
+
+ flex-basis:50%;
+ flex-grow:1;
+ }
+
+ button.flavor {
+
+ padding: 0;
+ background: none;
+
+ }
+
+ input[type=text],input[type=password] {
+ border:none;
+ border-radius:0;
+ width:100%;
+ padding:5px;
+ background-color:#333333;
+ color:#dddddd;
+ outline:none;
+
+ @media screen and (max-width: 500px) {
+ font-size:16px;
+ padding:10px;
+ }
+ }
+
+ .pwError {
+ div {
+ border:none;
+ border-radius:0;
+ width:100%;
+ padding:5px;
+ background-color:#663333;
+ color:#dddddd;
+ outline:none;
+ font-size:14px;
+ text-align:left;
+
+ @media screen and (max-width: 500px) {
+ font-size:16px;
+ padding:10px;
+ }
+ }
+ }
+
+ .lgBtnContainer {
+ display:flex;
+ position:relative;
+ left:20px;
+ width:calc( 100% - 40px );
+ gap:10px;
+ overflow:clip;
+ }
+
+ .fields {
+ display:flex;
+ flex-direction:column;
+ position:relative;
+ left:20px;
+ width:calc( 100% - 40px );
+ gap:5px;
+ overflow:clip;
+ }
+
+ /*
+ a {
+ text-decoration: none;
+ color:#999999;
+ font-size:14px;
+
+ @media screen and (max-width:500px) {
+ font-size:16px;
+ }
+
+ &::after {
+ content:" ➜";
+ font-size:0px;
+ opacity: 0;
+ transition-duration:250ms;
+ }
+
+ &:hover {
+ &::after {
+ font-size:13px;
+ opacity: 1;
+ transition-duration:250ms;
+ }
+ }
+ }
+ */
+ }
+
+ }
+
+ .loggedIn {
+ position:absolute;
+
+ /*
+ left:10px;
+ top:10px;
+ */
+
+ left:0px;
+ top:0px;
+ width:calc( 100% - 20px );
+ height:calc( 100% - 20px );
+ padding:10px;
+
+ overflow-y:auto;
+
+ h1 {
+ font-weight:600;
+ font-size:20px;
+ color: #AAAAAA;
+
+ @media screen and (max-width:500px) {
+ font-size:24px;
+ }
+
+ .monospace {
+ font-size:18px;
+ @media screen and (max-width:500px) {
+ font-size:22px;
+ }
+ }
+ }
+
+ .category {
+
+ p {
+ text-align:left;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/style/app/pulldown/files.scss b/src/style/app/pulldown/files.scss
new file mode 100644
index 00000000..24787d10
--- /dev/null
+++ b/src/style/app/pulldown/files.scss
@@ -0,0 +1,160 @@
+.pulldown_display[name=files] {
+ .notLoggedIn {
+ position:absolute;
+ top:50%;
+ left:0px;
+ transform:translateY(-50%);
+ width:100%;
+ text-align:center;
+
+ .flavor {
+ font-size:16px;
+ color:#999999;
+ margin: 0 0 10px 0;
+ }
+
+ button {
+ --col: #999999;
+
+ background-color: #232323;
+ color:var(--col);
+ font-size:14px;
+ border:1px solid var(--col);
+ padding:2px 20px 2px 20px;
+ cursor:pointer;
+ transition-duration:250ms;
+
+ &:hover {
+ background-color:#333333;
+ transition-duration:250ms;
+ --col:#BBBBBB;
+ }
+ }
+ }
+
+ .loggedIn {
+ display: flex;
+ flex-direction: column;
+ max-height:100%;
+ overflow:hidden;
+
+ .searchBar {
+ transition-duration:150ms;
+ background-color:#171717;
+ width:100%;
+ padding:8px;
+ color:#dddddd;
+ border:none;
+ border-bottom: 1px solid #aaaaaa;
+ outline: none;
+ border-radius:0px;
+ font-size:14px;
+
+ &:focus {
+ transition-duration:150ms;
+ border-bottom: 1px solid #dddddd;
+ }
+
+ @media screen and (max-width: 500px) {
+ padding:12px;
+ font-size:16px;
+ }
+ }
+
+ .fileList {
+ overflow-y:auto;
+ overflow-x:hidden;
+ padding:5px 0;
+
+ .flFile {
+ padding: 3px 8px;
+ position:relative;
+
+ @media screen and (max-width: 500px) {
+ padding:7px 12px;
+ }
+
+ .detail {
+ color:#777777;
+ font-size:14px;
+ position:relative;
+
+ @media screen and (max-width: 500px) {
+ font-size:16px;
+ }
+
+ img {
+ width: 14px;
+ height: 14px;
+
+ /* this is shit but it's the best way i can think of to do this */
+ /* other than flexbox but i don't feel like doing that rn */
+
+ position:relative;
+ top:2px;
+ }
+ }
+
+ h2 {
+ font-size:18px;
+ text-overflow:ellipsis;
+ overflow:hidden;
+ font-weight:600;
+
+ @media screen and (max-width: 500px) {
+ font-size:20px;
+ }
+ }
+
+ p, h2 {
+ margin:0 0 0 0;
+ white-space: nowrap;
+ }
+
+ button {
+ background-color:#00000000;
+ border:none;
+ outline:none;
+ cursor:pointer;
+
+ &.hitbox {
+ position:absolute;
+ left:0px;
+ top:0px;
+ height:100%;
+ width:100%;
+ z-index:10;
+ }
+
+ &.more {
+ min-height:100%;
+ width:auto;
+ aspect-ratio: 1 / 1;
+ z-index:11;
+ position:relative;
+
+ img {
+ margin:auto;
+ }
+ }
+ }
+
+ .flexCont {
+ display: flex;
+
+ .fileInfo {
+ width:100%;
+ min-width:0;
+ }
+ }
+
+ @media screen and (min-width:500px) {
+ &:hover {
+ background-color: #252525;
+ }
+ }
+
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/style/app/pulldown/help.scss b/src/style/app/pulldown/help.scss
new file mode 100644
index 00000000..d9effa2a
--- /dev/null
+++ b/src/style/app/pulldown/help.scss
@@ -0,0 +1,22 @@
+.pulldown_display[name=help] {
+
+ overflow-y:auto;
+
+ .faqGroup {
+ padding:6px 10px 4px 10px;
+
+ h2 {
+ font-weight: 400;
+ color:#DDDDDD;
+ font-size:16px;
+ margin:0 0 0 0;
+ }
+
+ p {
+ color:#999999;
+ font-size:16px;
+ margin:0 0 0 0;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/style/app/pulldown/modals.scss b/src/style/app/pulldown/modals.scss
new file mode 100644
index 00000000..492384b8
--- /dev/null
+++ b/src/style/app/pulldown/modals.scss
@@ -0,0 +1,115 @@
+.optPicker {
+
+ button, .inp {
+ position:relative;
+ width:100%;
+ height:50px;
+ background-color: #191919;
+ border:none;
+ border-bottom:1px solid #AAAAAA;
+ transition-duration:150ms;
+
+ img {
+ position:absolute;
+ left:13px;
+ top:50%;
+ transform:translateY(-50%);
+ }
+
+ p,input {
+ text-align:left;
+ position:absolute;
+ top:50%;
+ left:50px;
+ color:#DDDDDD;
+ transform:translateY(-50%);
+ font-size:14px;
+ background-color:#00000000;
+ border:none;
+
+ span {
+ color:#777777;
+ font-size:12px;
+ }
+ }
+
+ input {
+ height:100%;
+ width:calc(100% - 50px);
+ outline:none; /* bad idea but i don't even care anymore */
+ margin:0px;
+ padding:0px;
+ }
+
+ @media screen and (max-width:500px) {
+ height:70px;
+ p,input {
+ font-size:16px;
+ left:70px;
+
+ span {
+ font-size:14px;
+ }
+ }
+
+ input {
+ width:calc( 100% - 70px );
+ }
+
+ img {
+ width:30px;
+ height:30px;
+ left:20px;
+ }
+ }
+
+ }
+
+ button {
+ cursor:pointer;
+
+ &:hover {
+ transition-duration:150ms;
+ background-color: #252525;
+ }
+ }
+
+ .category {
+ border-bottom: 1px solid #AAAAAA;
+
+ p {
+ color: #AAAAAA;
+ font-size: 14px;
+ margin: 10px 0px 3px 0px;
+ text-align:center;
+
+ @media screen and (max-width:500px) {
+ font-size:16px;
+ }
+ }
+ }
+
+}
+
+.mdHitbox {
+ position:absolute;
+ width:100%;
+ height:100%;
+ top:0%;
+ left:0%;
+ cursor:pointer;
+ z-index: 0;
+ border:none;
+ background-color: #00000000;
+ outline:none;
+}
+
+.modal {
+ position:absolute;
+ background-color:#191919;
+ width:100%;
+ transform:translateY(-100%);
+ top:100%;
+ left:0%;
+ z-index: 1;
+}
diff --git a/src/style/app/topbar.scss b/src/style/app/topbar.scss
new file mode 100644
index 00000000..8016dc7f
--- /dev/null
+++ b/src/style/app/topbar.scss
@@ -0,0 +1,21 @@
+@use "../base";
+
+#topbar {
+ position:absolute;
+ left:0px;
+ top:0px;
+
+ width:100%;
+ height:40px;
+
+ /* hsl(210,9.1,12.9) */
+ background-color: rgb(30, 33, 36);
+
+ display:flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+
+ column-gap:5px;
+
+}
\ No newline at end of file
diff --git a/src/style/app/uploader/add_new_files.scss b/src/style/app/uploader/add_new_files.scss
new file mode 100644
index 00000000..598bcbe6
--- /dev/null
+++ b/src/style/app/uploader/add_new_files.scss
@@ -0,0 +1,115 @@
+#uploadWindow {
+ #add_new_files {
+ background-color:#191919;
+ border: 1px solid gray;
+ padding: 0px 0px 10px 0px;
+
+ p {
+ font-family: "Fira Code", monospace;
+ text-align: left;
+ margin: 0px 0px 0px 10px;
+ font-size: 30px;
+
+ span {
+ position:relative;
+
+ &._add_files_txt {
+ font-size:16px;
+ top:-4px;
+ left:10px;
+
+ @media screen and (max-width:500px) {
+ font-size:20px;
+ top:-6px;
+ left:10px;
+ }
+ }
+ }
+
+ @media screen and (max-width:500px) {
+ font-size: 40px;
+
+ span._add_files_txt {
+ font-size:20px;
+ top:-6px;
+ left:10px;
+ }
+ }
+ }
+
+ #file_add_btns {
+ width:calc( 100% - 20px );
+ margin:auto;
+ position:relative;
+ display:flex;
+ flex-direction:row;
+ column-gap:10px;
+
+ button, input[type=text] {
+ background-color:#333333;
+ color:#DDDDDD;
+ border:none;
+ border-radius: 0px;
+ outline:none;
+ padding:5px;
+
+ flex-basis:50%;
+ flex-grow:1;
+ transition-duration:250ms;
+
+ @media screen and (max-width: 500px) {
+ font-size:16px;
+ padding:10px;
+ }
+ }
+
+ button {
+ cursor:pointer;
+
+ &:hover {
+ @media screen and (min-width: 500px) {
+ transition-duration:250ms;
+ flex-basis: 60%;
+ }
+ background-color:#393939;
+ color: #ffffff;
+ }
+ }
+
+ .fileUpload {
+ width:100%;
+ height:100px;
+ position:relative;
+
+ background-color:#262626;
+ transition-duration:250ms;
+
+ input[type=file] {
+ opacity: 0;
+ position:absolute;
+ left:0px;
+ top:0px;
+ width:100%;
+ height:100%;
+ cursor:pointer;
+ }
+
+ p {
+ position:absolute;
+ top:50%;
+ transform:translateY(-50%);
+ font-size:12px;
+ width:100%;
+ text-align:center;
+ padding:0px;
+ margin: 0px;
+ }
+
+ &:hover {
+ transition-duration:250ms;
+ background-color:#292929;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/style/app/uploader/file.scss b/src/style/app/uploader/file.scss
new file mode 100644
index 00000000..54e3f32d
--- /dev/null
+++ b/src/style/app/uploader/file.scss
@@ -0,0 +1,59 @@
+// should probably start using mixins for thingss like this
+
+#uploadWindow {
+ .file {
+ background-color:#191919;
+ border: 1px solid gray;
+ padding: 10px;
+ overflow:clip;
+ position:relative;
+
+ h2 {
+ font-size: 16px;
+ margin: 0px;
+ font-weight:600;
+ width:calc( 100% - 20px );
+ }
+
+ input[type=text] {
+ background-color:#333333;
+ color:#DDDDDD;
+ border:none;
+ outline:none;
+ padding:5px;
+ position:relative;
+
+ width:100%;
+ transition-duration:250ms;
+
+ @media screen and (max-width: 500px) {
+ font-size:16px;
+ padding:10px;
+ }
+ }
+
+ .buttonContainer {
+ display:flex;
+ column-gap:10px;
+
+ button {
+ flex-basis: 50%;
+ flex-grow: 1;
+ padding:5px;
+ }
+ }
+
+ .uploadingContainer {
+ color: #AAAAAA;
+ }
+
+ .hitbox {
+ opacity:0;
+ position:absolute;
+ left:0px;
+ top:0px;
+ height:100%;
+ width:100%;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/style/app/uploads.scss b/src/style/app/uploads.scss
new file mode 100644
index 00000000..73268e9a
--- /dev/null
+++ b/src/style/app/uploads.scss
@@ -0,0 +1,72 @@
+@use "uploader/add_new_files";
+@use "uploader/file";
+
+#uploadWindow {
+ position:absolute;
+ left:50%;
+ top:50%;
+ transform:translate(-50%,-50%);
+ padding:10px 15px 10px 15px;
+ display:flex;
+ flex-direction: column;
+
+ width:350px;
+ @media screen and (min-width:500px) {
+ max-height: calc( 100% - 80px );
+ }
+
+ background-color:#222222;
+ color:#ddd;
+
+ h1, p, a {
+ margin: 0px;
+ font-size: 14px;
+ }
+
+ a {
+ color:#999;
+ }
+
+ h1 {
+ font-weight:600;
+ font-size: 25px;
+ }
+
+ .uploadContainer {
+ overflow:auto;
+ }
+
+ button {
+ cursor:pointer;
+ background-color:#393939;
+ color:#DDDDDD;
+ border:none;
+ outline:none;
+ padding:5px;
+ transition-duration: 250ms;
+ /*overflow:clip;*/
+
+ @media screen and (max-width: 500px) {
+ font-size:16px;
+ padding:10px;
+ }
+
+ &:hover {
+ transition-duration: 250ms;
+ background-color:#434343;
+ color: #ffffff;
+ }
+ }
+
+ @media screen and (max-width: 500px) {
+ width: calc( 100% - 20px );
+ height: calc( 100% - 20px );
+ border-radius:0px;
+ background-color:#00000000;
+
+ transform:none;
+ left:10px;
+ top:10px;
+ padding:0px;
+ }
+}
\ No newline at end of file
diff --git a/src/style/downloads.scss b/src/style/downloads.scss
new file mode 100644
index 00000000..81ff36ec
--- /dev/null
+++ b/src/style/downloads.scss
@@ -0,0 +1,26 @@
+// probably dont need to import the entire
+// uploads css file
+// so i might just make a separate file with mixins
+// and import them
+
+@use "app/uploads";
+@use "base";
+
+#appContent {
+ position:absolute;
+ left:0px;
+ top:0px;
+ width:100%;
+ height:100%;
+ background-image: linear-gradient(#333,base.$Background);
+
+ @media screen and (max-width:500px) {
+ background-image: linear-gradient(#303030,base.$Background);
+ }
+}
+
+#uploadWindow {
+ img, video, audio {
+ width:100%;
+ }
+}
\ No newline at end of file
diff --git a/src/style/error.scss b/src/style/error.scss
new file mode 100644
index 00000000..d40188a9
--- /dev/null
+++ b/src/style/error.scss
@@ -0,0 +1,20 @@
+@use "_base";
+
+.error {
+ font-size:20px;
+ color: lightslategray;
+
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%,-50%);
+
+ text-align:center;
+
+ .code {
+ font-size:25px;
+ font-family: "Inconsolata", monospace;
+ color: white;
+ }
+}
+
diff --git a/src/style/themes/classy.scss b/src/style/themes/classy.scss
new file mode 100644
index 00000000..7bdd382b
--- /dev/null
+++ b/src/style/themes/classy.scss
@@ -0,0 +1,168 @@
+#uploadWindow {
+ color: #FFFFFF
+}
+
+body {
+ background-color:#DDDDDD;
+}
+
+#appContent {
+ background: darkgray;
+ @media screen and (max-width:500px) {
+ background:white;
+ }
+}
+
+#uploadWindow {
+ background: white;
+
+ color:black;
+
+ h1, p, a {
+ margin: 0px;
+ font-size: 14px;
+ }
+
+ a {
+ color:rgb(153, 153, 153);
+ }
+
+ h1 {
+ font-weight:600;
+ font-size: 25px;
+ text-align:center;
+
+ @media screen and (max-width:500px) {
+ font-size: 30px;
+ }
+ }
+
+ & > p:nth-of-type(1) {
+ text-align:center;
+ font-style: italic;
+ font-weight:600;
+ font-size: 16px;
+ color:black !important;
+ @media screen and (max-width:500px) {
+ font-size: 21px;
+ }
+ }
+
+ button {
+ cursor:pointer;
+ color:black;
+ border:none;
+ outline:none;
+ padding:5px;
+ background: #AAAAAA;
+
+ @media screen and (max-width: 500px) {
+ font-size:16px;
+ padding:10px;
+ }
+
+ &:hover {
+ outline: 1px solid #333333;
+ color: black;
+ background-color:#AAAAAA;
+ }
+ }
+
+ & > button:nth-last-of-type(1) {
+ background-color:#66AAFF;
+ &:hover {
+ background-color:#66AAFF;
+ }
+ }
+
+ #add_new_files {
+ background-color: #AAAAAA66;
+ border:1px solid #AAAAAA;
+
+ #file_add_btns {
+ button, input[type=text] {
+ transition-duration:0s;
+
+ @media screen and (max-width: 500px) {
+ font-size:16px;
+ padding:10px;
+ }
+ }
+
+ input[type=text] {
+ font-family: "Fira Code", monospace;
+ background-color:#AAAAAA;
+ color:black;
+ }
+
+ button {
+ cursor:pointer;
+ background-color:#AAAAAA;
+ color: black;
+
+ &:hover {
+ flex-basis: 50%;
+ transition-duration:0s;
+ background-color:#AAAAAA;
+ color: black;
+ outline: 1px solid #333333;
+ }
+ }
+
+ .fileUpload {
+ background-color:#AAAAAA;
+ transition-duration:250ms;
+
+ &:hover {
+ transition-duration:0s;
+ background-color:#AAAAAA;
+ }
+ }
+ }
+ }
+
+ .file {
+ background-color: #AAAAAA66;
+ border: 1px solid #AAAAAA;
+
+ input[type=text] {
+ font-family: "Fira Code", monospace;
+ background-color:#AAAAAA;
+ color:black;
+ }
+ }
+
+}
+
+* {
+ /* nice scrollbars aren't needed on mobile so */
+ @media screen and (min-width:500px) {
+
+ &::-webkit-scrollbar {
+ width:5px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background-color:#AAAAAA;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background-color:#DDDDDD;
+
+ &:hover {
+ background-color:#FFFFFF;
+ }
+ }
+
+ }
+}
+
+#topbar {
+ background-color: #DDDDDD;
+}
+
+.error {
+ .code {
+ color: black;
+ }
+}
\ No newline at end of file
diff --git a/src/svelte/App.svelte b/src/svelte/App.svelte
new file mode 100644
index 00000000..0f633346
--- /dev/null
+++ b/src/svelte/App.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/svelte/elem/PulldownManager.svelte b/src/svelte/elem/PulldownManager.svelte
new file mode 100644
index 00000000..b3c23f92
--- /dev/null
+++ b/src/svelte/elem/PulldownManager.svelte
@@ -0,0 +1,49 @@
+
+
+
+{#if $pulldownOpen}
+
+
+
+
+
+{/if}
\ No newline at end of file
diff --git a/src/svelte/elem/Topbar.svelte b/src/svelte/elem/Topbar.svelte
new file mode 100644
index 00000000..81670692
--- /dev/null
+++ b/src/svelte/elem/Topbar.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {#if $pulldownOpen}
+
+ {/if}
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/svelte/elem/UploadWindow.svelte b/src/svelte/elem/UploadWindow.svelte
new file mode 100644
index 00000000..0b2b1366
--- /dev/null
+++ b/src/svelte/elem/UploadWindow.svelte
@@ -0,0 +1,231 @@
+
+
+
+ Made with {Math.floor(Math.random()*10)==0 ? "🐟" : "❤"} by @nbitzz — source
+
+
+
\ No newline at end of file
diff --git a/src/svelte/elem/prompts/OptionPicker.svelte b/src/svelte/elem/prompts/OptionPicker.svelte
new file mode 100644
index 00000000..68cef38b
--- /dev/null
+++ b/src/svelte/elem/prompts/OptionPicker.svelte
@@ -0,0 +1,78 @@
+
+
+{#if activeModal}
+
+ {/if}
+
+
\ No newline at end of file
diff --git a/src/svelte/elem/pulldowns/Help.svelte b/src/svelte/elem/pulldowns/Help.svelte
new file mode 100644
index 00000000..104af4af
--- /dev/null
+++ b/src/svelte/elem/pulldowns/Help.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+
+ {#each faq as question}
+
+
{question.question}
+
{question.answer}
+
+ {/each}
+
\ No newline at end of file
diff --git a/src/svelte/elem/pulldowns/Pulldown.svelte b/src/svelte/elem/pulldowns/Pulldown.svelte
new file mode 100644
index 00000000..f2efa94c
--- /dev/null
+++ b/src/svelte/elem/pulldowns/Pulldown.svelte
@@ -0,0 +1,14 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/svelte/elem/stores.mjs b/src/svelte/elem/stores.mjs
new file mode 100644
index 00000000..d624dd73
--- /dev/null
+++ b/src/svelte/elem/stores.mjs
@@ -0,0 +1,35 @@
+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()
\ No newline at end of file
diff --git a/src/svelte/elem/transition/_void.js b/src/svelte/elem/transition/_void.js
new file mode 100644
index 00000000..c9bfd0dd
--- /dev/null
+++ b/src/svelte/elem/transition/_void.js
@@ -0,0 +1,20 @@
+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;
+ `
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/svelte/elem/transition/padding_scaleY.js b/src/svelte/elem/transition/padding_scaleY.js
new file mode 100644
index 00000000..c19a44bc
--- /dev/null
+++ b/src/svelte/elem/transition/padding_scaleY.js
@@ -0,0 +1,18 @@
+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};` : ""}
+ `
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/svelte/elem/uploader/AttachmentZone.svelte b/src/svelte/elem/uploader/AttachmentZone.svelte
new file mode 100644
index 00000000..7d705595
--- /dev/null
+++ b/src/svelte/elem/uploader/AttachmentZone.svelte
@@ -0,0 +1,93 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index 97159260..1867d13f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,104 +1,104 @@
-{
- "include":["src/**/*"],
- "compilerOptions": {
- /* Visit https://aka.ms/tsconfig to read more about this file */
-
- /* Projects */
- // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
- // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
- // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
- // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
- // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
- // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
-
- /* Language and Environment */
- "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
- // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
- // "jsx": "preserve", /* Specify what JSX code is generated. */
- // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
- // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
- // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
- // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
- // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
- // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
- // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
- // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
- // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
-
- /* Modules */
- "module": "commonjs", /* Specify what module code is generated. */
- // "rootDir": "./src/", /* Specify the root folder within your source files. */
- // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
- // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
- // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
- // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
- // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
- // "types": [], /* Specify type package names to be included without being referenced in a source file. */
- // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
- // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
- // "resolveJsonModule": true, /* Enable importing .json files. */
- // "noResolve": true, /* Disallow 'import's, 'require's or ''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/", /* 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. */
- }
-}
+{
+ "include":["src/server/**/*"],
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig to read more about this file */
+
+ /* Projects */
+ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
+ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
+ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
+ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
+ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
+
+ /* Language and Environment */
+ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+ // "jsx": "preserve", /* Specify what JSX code is generated. */
+ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
+ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
+ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
+ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
+ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
+
+ /* Modules */
+ "module": "commonjs", /* Specify what module code is generated. */
+ // "rootDir": "./src/", /* Specify the root folder within your source files. */
+ // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
+ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
+ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
+ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
+ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
+ // "types": [], /* Specify type package names to be included without being referenced in a source file. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
+ // "resolveJsonModule": true, /* Enable importing .json files. */
+ // "noResolve": true, /* Disallow 'import's, 'require's or ''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. */
+ }
+}