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