From 1ed1acca1c367d9576e4db000355c247dacb3bee Mon Sep 17 00:00:00 2001
From: stringsplit <77242831+nbitzz@users.noreply.github.com>
Date: Tue, 3 Oct 2023 20:10:08 -0700
Subject: [PATCH] api-v1: apihandler
---
src/server/routes/api.ts | 78 ++++
src/server/routes/api/v0/adminRoutes.ts | 236 +++++++++++
src/server/routes/api/v0/api.json | 10 +
src/server/routes/api/v0/authRoutes.ts | 464 ++++++++++++++++++++++
src/server/routes/api/v0/fileApiRoutes.ts | 98 +++++
src/server/routes/api/v0/primaryApi.ts | 181 +++++++++
src/server/routes/api/v1/api.json | 7 +
7 files changed, 1074 insertions(+)
create mode 100644 src/server/routes/api.ts
create mode 100644 src/server/routes/api/v0/adminRoutes.ts
create mode 100644 src/server/routes/api/v0/api.json
create mode 100644 src/server/routes/api/v0/authRoutes.ts
create mode 100644 src/server/routes/api/v0/fileApiRoutes.ts
create mode 100644 src/server/routes/api/v0/primaryApi.ts
create mode 100644 src/server/routes/api/v1/api.json
diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts
new file mode 100644
index 0000000..3286a4a
--- /dev/null
+++ b/src/server/routes/api.ts
@@ -0,0 +1,78 @@
+import { Router } from "express";
+import { readFile, readdir } from "fs/promises";
+import Files from "../lib/files";
+
+const APIDirectory = __dirname+"/api"
+
+interface APIMount {
+ file: string
+ to: string
+}
+
+type APIMountResolvable = string | APIMount
+
+interface APIDefinition {
+ name: string
+ baseURL: string
+ mount: APIMountResolvable[]
+}
+
+function resolveMount(mount: APIMountResolvable): APIMount {
+ return typeof mount == "string" ? { file: mount, to: "/"+mount } : mount
+}
+
+class APIVersion {
+ readonly definition: APIDefinition;
+ readonly apiPath: string;
+ readonly root: Router = Router();
+
+ constructor(definition: APIDefinition, files: Files) {
+
+ this.definition = definition;
+ this.apiPath = APIDirectory + "/" + definition.name
+
+ for (let _mount of definition.mount) {
+ let mount = resolveMount(_mount)
+ // no idea if there's a better way to do this but this is all i can think of
+ let route = require(`${this.apiPath}/${mount.file}.js`) as (files:Files)=>Router
+ this.root.use(mount.to, route(files))
+ }
+ }
+}
+
+export default class APIRouter {
+
+ readonly files: Files
+ readonly root: Router = Router();
+
+ constructor(files: Files) {
+ this.files = files;
+ }
+
+ /**
+ * @description Mounts an APIDefinition to the APIRouter.
+ * @param definition Definition to mount.
+ */
+
+ private mount(definition: APIDefinition) {
+
+ console.log(`mounting APIDefinition ${definition.name}`)
+
+ this.root.use(
+ definition.baseURL,
+ (new APIVersion(definition, this.files)).root
+ )
+
+ }
+
+ async loadAPIMethods() {
+
+ let files = await readdir(APIDirectory)
+ for (let v of files) { /// temporary. need to figure out something else for this
+ let def = JSON.parse((await readFile(`${process.cwd()}/src/server/routes/api/${v}/api.json`)).toString()) as APIDefinition
+ this.mount(def)
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/server/routes/api/v0/adminRoutes.ts b/src/server/routes/api/v0/adminRoutes.ts
new file mode 100644
index 0000000..fa4445e
--- /dev/null
+++ b/src/server/routes/api/v0/adminRoutes.ts
@@ -0,0 +1,236 @@
+import bodyParser from "body-parser";
+import { Router } from "express";
+import * as Accounts from "../../../lib/accounts";
+import * as auth from "../../../lib/auth";
+import bytes from "bytes"
+import {writeFile} from "fs";
+import { sendMail } from "../../../lib/mail";
+import { getAccount, requiresAccount, requiresAdmin, requiresPermissions } from "../../../lib/middleware"
+
+import ServeError from "../../../lib/errors";
+import Files from "../../../lib/files";
+
+let parser = bodyParser.json({
+ type: ["text/plain","application/json"]
+})
+
+export let adminRoutes = Router();
+adminRoutes
+ .use(getAccount)
+ .use(requiresAccount)
+ .use(requiresAdmin)
+ .use(requiresPermissions("admin"))
+
+let config = require(`${process.cwd()}/config.json`)
+
+module.exports = function(files: Files) {
+
+
+ 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) => {
+
+ 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) => {
+
+ if (typeof req.body.target !== "string" || typeof req.body.owner !== "string") {
+ res.status(404)
+ res.send()
+ return
+ }
+
+ let targetFile = files.getFilePointer(req.body.target)
+ if (!targetFile) {
+ res.status(404)
+ res.send()
+ return
+ }
+
+ let newOwner = Accounts.getFromUsername(req.body.owner || "")
+
+ // clear old owner
+
+ if (targetFile.owner) {
+ let oldOwner = Accounts.getFromId(targetFile.owner)
+ if (oldOwner) {
+ Accounts.files.deindex(oldOwner.id, req.body.target)
+ }
+ }
+
+ if (newOwner) {
+ Accounts.files.index(newOwner.id, req.body.target)
+ }
+ targetFile.owner = newOwner ? newOwner.id : undefined;
+
+ files.writeFile(req.body.target, targetFile).then(() => {
+ res.send()
+ }).catch(() => {
+ res.status(500)
+ res.send()
+ }) // wasting a reassignment but whatee
+
+ })
+
+ adminRoutes.post("/idchange", parser, (req,res) => {
+
+ if (typeof req.body.target !== "string" || typeof req.body.new !== "string") {
+ res.status(400)
+ res.send()
+ return
+ }
+
+ let targetFile = files.getFilePointer(req.body.target)
+ if (!targetFile) {
+ res.status(404)
+ res.send()
+ return
+ }
+
+ if (files.getFilePointer(req.body.new)) {
+ res.status(400)
+ res.send()
+ return
+ }
+
+ if (targetFile.owner) {
+ Accounts.files.deindex(targetFile.owner, req.body.target)
+ Accounts.files.index(targetFile.owner, req.body.new)
+ }
+ delete files.files[req.body.target]
+
+ files.writeFile(req.body.new, targetFile).then(() => {
+ res.send()
+ }).catch(() => {
+ files.files[req.body.target] = req.body.new
+
+ if (targetFile.owner) {
+ Accounts.files.deindex(targetFile.owner, req.body.new)
+ Accounts.files.index(targetFile.owner, req.body.target)
+ }
+
+ res.status(500)
+ res.send()
+ })
+
+ })
+
+ return adminRoutes
+}
\ No newline at end of file
diff --git a/src/server/routes/api/v0/api.json b/src/server/routes/api/v0/api.json
new file mode 100644
index 0000000..ad0bffb
--- /dev/null
+++ b/src/server/routes/api/v0/api.json
@@ -0,0 +1,10 @@
+{
+ "name": "v0",
+ "baseURL": "/",
+ "mount": [
+ { "file": "primaryApi", "to": "/" },
+ { "file": "adminRoutes", "to": "/admin" },
+ { "file": "authRoutes", "to": "/auth" },
+ { "file": "fileApiRoutes", "to": "/files" }
+ ]
+}
\ No newline at end of file
diff --git a/src/server/routes/api/v0/authRoutes.ts b/src/server/routes/api/v0/authRoutes.ts
new file mode 100644
index 0000000..a2dd3fb
--- /dev/null
+++ b/src/server/routes/api/v0/authRoutes.ts
@@ -0,0 +1,464 @@
+import bodyParser from "body-parser";
+import { Router } from "express";
+import * as Accounts from "../../../lib/accounts";
+import * as auth from "../../../lib/auth";
+import { sendMail } from "../../../lib/mail";
+import { getAccount, noAPIAccess, requiresAccount, requiresPermissions } from "../../../lib/middleware"
+import { accountRatelimit } from "../../../lib/ratelimit"
+
+import ServeError from "../../../lib/errors";
+import Files, { FileVisibility, generateFileId, id_check_regex } from "../../../lib/files";
+
+import { writeFile } from "fs";
+
+let parser = bodyParser.json({
+ type: ["text/plain","application/json"]
+})
+
+export let authRoutes = Router();
+authRoutes.use(getAccount)
+
+let config = require(`${process.cwd()}/config.json`)
+
+module.exports = function(files: Files) {
+
+ authRoutes.post("/login", parser, (req,res) => {
+ if (typeof req.body.username != "string" || typeof req.body.password != "string") {
+ ServeError(res,400,"please provide a username or password")
+ return
+ }
+
+ if (auth.validate(req.cookies.auth)) return
+
+ /*
+ check if account exists
+ */
+
+ let acc = Accounts.getFromUsername(req.body.username)
+
+ if (!acc) {
+ ServeError(res,401,"username or password incorrect")
+ return
+ }
+
+ if (!Accounts.password.check(acc.id,req.body.password)) {
+ ServeError(res,401,"username or password incorrect")
+ return
+ }
+
+ /*
+ assign token
+ */
+
+ res.cookie("auth",auth.create(acc.id,(3*24*60*60*1000)))
+ res.status(200)
+ res.end()
+ })
+
+ authRoutes.post("/create", parser, (req,res) => {
+ if (!config.accounts.registrationEnabled) {
+ ServeError(res,403,"account registration disabled")
+ return
+ }
+
+ if (auth.validate(req.cookies.auth)) return
+
+ if (typeof req.body.username != "string" || typeof req.body.password != "string") {
+ ServeError(res,400,"please provide a username or password")
+ return
+ }
+
+ /*
+ check if account exists
+ */
+
+ let acc = Accounts.getFromUsername(req.body.username)
+
+ if (acc) {
+ ServeError(res,400,"account with this username already exists")
+ return
+ }
+
+ if (req.body.username.length < 3 || req.body.username.length > 20) {
+ ServeError(res,400,"username must be over or equal to 3 characters or under or equal to 20 characters in length")
+ return
+ }
+
+ if ((req.body.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] != req.body.username) {
+ ServeError(res,400,"username contains invalid characters")
+ return
+ }
+
+ if (req.body.password.length < 8) {
+ ServeError(res,400,"password must be 8 characters or longer")
+ return
+ }
+
+ Accounts.create(req.body.username,req.body.password)
+ .then((newAcc) => {
+ /*
+ assign token
+ */
+
+ res.cookie("auth",auth.create(newAcc,(3*24*60*60*1000)))
+ res.status(200)
+ res.end()
+ })
+ .catch(() => {
+ ServeError(res,500,"internal server error")
+ })
+ })
+
+ authRoutes.post("/logout", (req,res) => {
+ if (!auth.validate(req.cookies.auth)) {
+ ServeError(res, 401, "not logged in")
+ return
+ }
+
+ auth.invalidate(req.cookies.auth)
+ res.send("logged out")
+ })
+
+ authRoutes.post("/dfv", requiresAccount, requiresPermissions("manage"), parser, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ if (['public','private','anonymous'].includes(req.body.defaultFileVisibility)) {
+ acc.defaultFileVisibility = req.body.defaultFileVisibility
+ Accounts.save()
+ res.send(`dfv has been set to ${acc.defaultFileVisibility}`)
+ } else {
+ res.status(400)
+ res.send("invalid dfv")
+ }
+ })
+
+ authRoutes.post("/customcss", requiresAccount, requiresPermissions("customize"), parser, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ if (typeof req.body.fileId != "string") req.body.fileId = undefined;
+
+ if (
+
+ !req.body.fileId
+ || (req.body.fileId.match(id_check_regex) == req.body.fileId
+ && req.body.fileId.length <= config.maxUploadIdLength)
+
+ ) {
+ acc.customCSS = req.body.fileId || undefined
+ if (!req.body.fileId) delete acc.customCSS
+ Accounts.save()
+ res.send(`custom css saved`)
+ } else {
+ res.status(400)
+ res.send("invalid fileid")
+ }
+ })
+
+ authRoutes.post("/embedcolor", requiresAccount, requiresPermissions("customize"), parser, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ if (typeof req.body.color != "string") req.body.color = undefined;
+
+ if (
+
+ !req.body.color
+ || (req.body.color.toLowerCase().match(/[a-f0-9]+/) == req.body.color.toLowerCase())
+ && req.body.color.length == 6
+
+ ) {
+ if (!acc.embed) acc.embed = {}
+ acc.embed.color = req.body.color || undefined
+ if (!req.body.color) delete acc.embed.color
+ Accounts.save()
+ res.send(`custom embed color saved`)
+ } else {
+ res.status(400)
+ res.send("invalid hex code")
+ }
+ })
+
+ authRoutes.post("/embedsize", requiresAccount, requiresPermissions("customize"), parser, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ if (typeof req.body.largeImage != "boolean") req.body.color = false;
+
+ if (!acc.embed) acc.embed = {}
+ acc.embed.largeImage = req.body.largeImage
+ if (!req.body.largeImage) delete acc.embed.largeImage
+ Accounts.save()
+ res.send(`custom embed image size saved`)
+ })
+
+ authRoutes.post("/delete_account", requiresAccount, noAPIAccess, parser, async (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ let accId = acc.id
+
+ auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
+ auth.invalidate(v.token)
+ })
+
+ let cpl = () => Accounts.deleteAccount(accId).then(_ => res.send("account deleted"))
+
+ if (req.body.deleteFiles) {
+ let f = acc.files.map(e=>e) // make shallow copy so that iterating over it doesnt Die
+ for (let v of f) {
+ files.unlink(v,true).catch(err => console.error(err))
+ }
+
+ writeFile(process.cwd()+"/.data/files.json",JSON.stringify(files.files), (err) => {
+ if (err) console.log(err)
+ cpl()
+ })
+ } else cpl()
+ })
+
+ authRoutes.post("/change_username", requiresAccount, noAPIAccess, parser, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ if (typeof req.body.username != "string" || req.body.username.length < 3 || req.body.username.length > 20) {
+ ServeError(res,400,"username must be between 3 and 20 characters in length")
+ return
+ }
+
+ let _acc = Accounts.getFromUsername(req.body.username)
+
+ if (_acc) {
+ ServeError(res,400,"account with this username already exists")
+ return
+ }
+
+ if ((req.body.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] != req.body.username) {
+ ServeError(res,400,"username contains invalid characters")
+ return
+ }
+
+ acc.username = req.body.username
+ Accounts.save()
+
+ if (acc.email) {
+ sendMail(acc.email, `Your login details have been updated`, `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, noAPIAccess, accountRatelimit({ requests: 4, per: 60*60*1000 }), parser, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+
+ if (typeof req.body.email != "string" || !req.body.email) {
+ ServeError(res,400, "supply an email")
+ return
+ }
+
+ let vcode = verificationCodes.get(acc.id)
+
+ // delete previous if any
+ let e = vcode?.expiry
+ if (e) clearTimeout(e)
+ verificationCodes.delete(acc?.id||"")
+
+ let code = generateFileId(12).toUpperCase()
+
+ // set
+
+ verificationCodes.set(acc.id, {
+ code,
+ email: req.body.email,
+ expiry: setTimeout( () => verificationCodes.delete(acc?.id||""), 15*60*1000)
+ })
+
+ // this is a mess but it's fine
+
+ sendMail(req.body.email, `Hey there, ${acc.username} - let's connect your email`, `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, noAPIAccess, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+
+ let vcode = verificationCodes.get(acc.id)
+
+ if (!vcode) { ServeError(res, 400, "nothing to confirm"); return }
+
+ if (typeof req.params.code == "string" && req.params.code.toUpperCase() == vcode.code) {
+ acc.email = vcode.email
+ Accounts.save();
+
+ let e = verificationCodes.get(acc?.id||"")?.expiry
+ if (e) clearTimeout(e)
+ verificationCodes.delete(acc?.id||"")
+
+ res.redirect("/")
+ } else {
+ ServeError(res, 400, "invalid code")
+ }
+ })
+
+ authRoutes.post("/remove_email", requiresAccount, noAPIAccess, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ if (acc.email) {
+ delete acc.email;
+ Accounts.save()
+ res.send("email detached")
+ }
+ else ServeError(res, 400, "email not attached")
+ })
+
+ let pwReset = new Map()
+ 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, noAPIAccess, parser, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ if (typeof req.body.password != "string" || req.body.password.length < 8) {
+ ServeError(res,400,"password must be 8 characters or longer")
+ return
+ }
+
+ let accId = acc.id
+
+ Accounts.password.set(accId,req.body.password)
+
+ auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
+ auth.invalidate(v.token)
+ })
+
+ if (acc.email) {
+ sendMail(acc.email, `Your login details have been updated`, `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, noAPIAccess, (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ let accId = acc.id
+
+ auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
+ auth.invalidate(v.token)
+ })
+
+ res.send("logged out all sessions")
+ })
+
+ authRoutes.get("/me", requiresAccount, requiresPermissions("user"), (req,res) => {
+ let acc = res.locals.acc as Accounts.Account
+
+ let sessionToken = auth.tokenFor(req)
+ let accId = acc.id
+ res.send({
+ ...acc,
+ sessionCount: auth.AuthTokens.filter(e => e.type != "App" && e.account == accId && (e.expire > Date.now() || !e.expire)).length,
+ sessionExpires: auth.AuthTokens.find(e => e.token == sessionToken)?.expire,
+ password: undefined,
+ email:
+ auth.getType(sessionToken) == "User" || auth.getPermissions(sessionToken)?.includes("email")
+ ? acc.email
+ : undefined
+ })
+ })
+
+ authRoutes.get("/customCSS", (req,res) => {
+ let acc = res.locals.acc
+ if (acc?.customCSS) res.redirect(`/file/${acc.customCSS}`)
+ else res.send("")
+ })
+
+ return authRoutes
+}
\ No newline at end of file
diff --git a/src/server/routes/api/v0/fileApiRoutes.ts b/src/server/routes/api/v0/fileApiRoutes.ts
new file mode 100644
index 0000000..bb1953c
--- /dev/null
+++ b/src/server/routes/api/v0/fileApiRoutes.ts
@@ -0,0 +1,98 @@
+import bodyParser from "body-parser";
+import { Router } from "express";
+import * as Accounts from "../../../lib/accounts";
+import * as auth from "../../../lib/auth";
+import bytes from "bytes"
+import {writeFile} from "fs";
+
+import ServeError from "../../../lib/errors";
+import Files from "../../../lib/files";
+import { getAccount, requiresAccount, requiresPermissions } from "../../../lib/middleware";
+
+let parser = bodyParser.json({
+ type: ["text/plain","application/json"]
+})
+
+export let fileApiRoutes = Router();
+
+let config = require(`${process.cwd()}/config.json`)
+
+
+module.exports = function(files: Files) {
+
+ fileApiRoutes.use(getAccount);
+
+ fileApiRoutes.get("/list", requiresAccount, requiresPermissions("user"), (req,res) => {
+
+ let acc = res.locals.acc as Accounts.Account
+
+ if (!acc) return
+ let accId = acc.id
+
+ res.send(acc.files.map((e) => {
+ let fp = files.getFilePointer(e)
+ if (!fp) { Accounts.files.deindex(accId, e); return null }
+ return {
+ ...fp,
+ messageids: null,
+ owner: null,
+ id:e
+ }
+ }).filter(e=>e))
+
+ })
+
+ fileApiRoutes.post("/manage", parser, requiresPermissions("manage"), (req,res) => {
+
+ let acc = res.locals.acc as Accounts.Account
+
+ if (!acc) return
+ if (!req.body.target || !(typeof req.body.target == "object") || req.body.target.length < 1) return
+
+ let modified = 0
+
+ req.body.target.forEach((e:string) => {
+ if (!acc.files.includes(e)) return
+
+ let fp = files.getFilePointer(e)
+
+ if (fp.reserved) {
+ return
+ }
+
+ switch( req.body.action ) {
+ case "delete":
+ files.unlink(e, true)
+ modified++;
+ break;
+
+ case "changeFileVisibility":
+ if (!["public","anonymous","private"].includes(req.body.value)) return;
+ files.files[e].visibility = req.body.value;
+ modified++;
+ break;
+
+ case "setTag":
+ if (!req.body.value) delete files.files[e].tag
+ else {
+ if (req.body.value.toString().length > 30) return
+ files.files[e].tag = req.body.value.toString().toLowerCase()
+ }
+ modified++;
+ break;
+ }
+ })
+
+ Accounts.save().then(() => {
+ writeFile(process.cwd()+"/.data/files.json",JSON.stringify(files.files), (err) => {
+ if (err) console.log(err)
+ res.contentType("text/plain")
+ res.send(`modified ${modified} files`)
+ })
+ }).catch((err) => console.error(err))
+
+
+ })
+
+ return fileApiRoutes
+}
\ No newline at end of file
diff --git a/src/server/routes/api/v0/primaryApi.ts b/src/server/routes/api/v0/primaryApi.ts
new file mode 100644
index 0000000..3881b75
--- /dev/null
+++ b/src/server/routes/api/v0/primaryApi.ts
@@ -0,0 +1,181 @@
+import bodyParser from "body-parser";
+import express, { Router } from "express";
+import * as Accounts from "../../../lib/accounts";
+import * as auth from "../../../lib/auth";
+import axios, { AxiosResponse } from "axios"
+import { type Range } from "range-parser";
+import multer, {memoryStorage} from "multer"
+
+import ServeError from "../../../lib/errors";
+import Files from "../../../lib/files";
+import { getAccount, requiresPermissions } from "../../../lib/middleware";
+
+let parser = bodyParser.json({
+ type: ["text/plain","application/json"]
+})
+
+export let primaryApi = Router();
+
+const multerSetup = multer({storage:memoryStorage()})
+
+let config = require(`${process.cwd()}/config.json`)
+
+primaryApi.use(getAccount);
+
+module.exports = function(files: Files) {
+
+ primaryApi.get(["/file/:fileId", "/cpt/:fileId/*", "/:fileId"], async (req:express.Request,res:express.Response) => {
+
+ let acc = res.locals.acc as Accounts.Account
+
+ let file = files.getFilePointer(req.params.fileId)
+ res.setHeader("Access-Control-Allow-Origin", "*")
+ res.setHeader("Content-Security-Policy","sandbox allow-scripts")
+ if (req.query.attachment == "1") res.setHeader("Content-Disposition", "attachment")
+
+ if (file) {
+
+ if (file.visibility == "private" && acc?.id != file.owner) {
+ ServeError(res,403,"you do not own this file")
+ return
+ }
+
+ let range: Range | undefined
+
+ res.setHeader("Content-Type",file.mime)
+ if (file.sizeInBytes) {
+ res.setHeader("Content-Length",file.sizeInBytes)
+
+ if (file.chunkSize) {
+ let rng = req.range(file.sizeInBytes)
+ if (rng) {
+
+ // error handling
+ if (typeof rng == "number") {
+ res.status(rng == -1 ? 416 : 400).send()
+ return
+ }
+ if (rng.type != "bytes") {
+ res.status(400).send();
+ return
+ }
+
+ // set ranges var
+ let rngs = Array.from(rng)
+ if (rngs.length != 1) { res.status(400).send(); return }
+ range = rngs[0]
+
+ }
+ }
+ }
+
+ // supports ranges
+
+
+ files.readFileStream(req.params.fileId, range).then(async stream => {
+
+ if (range) {
+ res.status(206)
+ res.header("Content-Length", (range.end-range.start + 1).toString())
+ res.header("Content-Range", `bytes ${range.start}-${range.end}/${file.sizeInBytes}`)
+ }
+ stream.pipe(res)
+
+ }).catch((err) => {
+ ServeError(res,err.status,err.message)
+ })
+
+ } else {
+ ServeError(res, 404, "file not found")
+ }
+
+ })
+
+ primaryApi.head(["/file/:fileId", "/cpt/:fileId/*", "/:fileId"], (req: express.Request, res:express.Response) => {
+ let file = files.getFilePointer(req.params.fileId)
+ res.setHeader("Access-Control-Allow-Origin", "*")
+ res.setHeader("Content-Security-Policy","sandbox allow-scripts")
+ if (req.query.attachment == "1") res.setHeader("Content-Disposition", "attachment")
+ if (!file) {
+ res.status(404)
+ res.send()
+ } else {
+ res.setHeader("Content-Type",file.mime)
+ if (file.sizeInBytes) {
+ res.setHeader("Content-Length",file.sizeInBytes)
+ }
+ if (file.chunkSize) {
+ res.setHeader("Accept-Ranges", "bytes")
+ }
+ }
+ })
+
+ // upload handlers
+
+ primaryApi.post("/upload", requiresPermissions("upload"), multerSetup.single('file'), async (req,res) => {
+
+ let acc = res.locals.acc as Accounts.Account
+
+ if (req.file) {
+ try {
+ let prm = req.header("monofile-params")
+ let params:{[key:string]:any} = {}
+ if (prm) {
+ params = JSON.parse(prm)
+ }
+
+ files.uploadFile({
+ owner: acc?.id,
+
+ uploadId:params.uploadId,
+ name:req.file.originalname,
+ mime:req.file.mimetype
+ },req.file.buffer)
+ .then((uID) => res.send(uID))
+ .catch((stat) => {
+ res.status(stat.status);
+ res.send(`[err] ${stat.message}`)
+ })
+ } catch {
+ res.status(400)
+ res.send("[err] bad request")
+ }
+ } else {
+ res.status(400)
+ res.send("[err] bad request")
+ }
+ })
+
+ primaryApi.post("/clone", requiresPermissions("upload"), bodyParser.json({type: ["text/plain","application/json"]}) ,(req,res) => {
+
+ let acc = res.locals.acc as Accounts.Account
+
+ try {
+ axios.get(req.body.url,{responseType:"arraybuffer"}).then((data:AxiosResponse) => {
+
+ files.uploadFile({
+ owner: acc?.id,
+
+ name:req.body.url.split("/")[req.body.url.split("/").length-1] || "generic",
+ mime:data.headers["content-type"],
+ uploadId:req.body.uploadId
+ },Buffer.from(data.data))
+ .then((uID) => res.send(uID))
+ .catch((stat) => {
+ res.status(stat.status);
+ res.send(`[err] ${stat.message}`)
+ })
+
+ }).catch((err) => {
+ console.log(err)
+ res.status(400)
+ res.send(`[err] failed to fetch data`)
+ })
+ } catch {
+ res.status(500)
+ res.send("[err] an error occured")
+ }
+ })
+
+ return primaryApi
+}
\ No newline at end of file
diff --git a/src/server/routes/api/v1/api.json b/src/server/routes/api/v1/api.json
new file mode 100644
index 0000000..5c20663
--- /dev/null
+++ b/src/server/routes/api/v1/api.json
@@ -0,0 +1,7 @@
+{
+ "name": "v1",
+ "baseURL": "/api/v1",
+ "mount": [
+ "account", "admin", "file", "public"
+ ]
+}
\ No newline at end of file