diff --git a/.env.example b/.env.example index ff969d1..e27e100 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ MIGRATE_TXT="migrate.txt" -DB_FILE_NAME="db.sqlite3" \ No newline at end of file +DB_FILE_NAME="db.sqlite3" +MAILCONNECT_ROOT_DIR="/app" \ No newline at end of file diff --git a/.gitea/workflows/bump.yml b/.gitea/workflows/bump.yml new file mode 100644 index 0000000..2fc7be1 --- /dev/null +++ b/.gitea/workflows/bump.yml @@ -0,0 +1,40 @@ +name: Bump Dependencies + +on: + push: + branches: + - main + schedule: + - cron: "0 0 * * *" + - cron: "0 12 * * *" + +jobs: + update-dependencies: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install Dependencies + run: bun install + + - name: Update Dependencies + run: bun update + + - name: Commit and Push Changes + env: + USERNAME_GITEA: ${{ secrets.USERNAME_GITEA }} + TOKEN_GITEA: ${{ secrets.TOKEN_GITEA }} + run: | + git config --global user.name "LibreCloud Actions Bot" + git config --global user.email "git@pontusmail.org" + git remote set-url origin https://${USERNAME_GITEA}:${TOKEN_GITEA}@git.pontusmail.org/librecloud/mail-connect.git + git add . + git commit -m "chore: bump dependencies" || exit 0 + git push origin main \ No newline at end of file diff --git a/README.md b/README.md index 250b8a7..a41be60 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ We provide an extendable API which interacts with the `setup` utility via a Dock ## What this API is NOT -This API is insecure by nature, however not completely. It's meant to be an internal API, and used in frontends which have their own protection systems in place. Think about it... would you like me to direct your mailserver security? I sure hope not... +This API is insecure by nature, however not completely. `mail-connect` is intended to be an API which is used internally _only_. The systems connected to this API should have proper protections against abuse. Think about it... would you like me to direct your mailserver security? I sure hope not... As such, users who have access to this API are able to create unlimited accounts, and modify anyone's email address. Thus, your code should be the only user of this API. Once again, **do not make this API public**. @@ -45,8 +45,10 @@ All features marked with an **E** are extended features, and are not a part of t ```bash touch migrate.txt # put emails (one per line) which already exist on the server which users can claim cp .env.example .env # you don't need to change anything here - vim ratelimit.json # optional, customize to your liking + vim ratelimit.json # optional, customize to your liking... ``` + + **Note:** If you are running mail-connect outside a Docker container (or changing the binds), please change the `MAILCONNECT_ROOT_DIR` to match your environment. 4. **Build and run the container** diff --git a/docker-compose.yml b/docker-compose.yml index a58573f..4bf084b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: mail-connect: - build: . + image: "git.pontusmail.org/librecloud/web:latest" container_name: mail-connect env_file: ".env" ports: diff --git a/package.json b/package.json index 2a33c5b..f46a969 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "mail-connect", "module": "src/server.ts", "type": "module", + "scripts": { + "start": "bun src/server.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, "devDependencies": { "@types/bun": "latest", "@types/dockerode": "^3.3.35", @@ -15,6 +19,7 @@ }, "dependencies": { "@types/better-sqlite3": "^7.6.12", + "@types/figlet": "^1.7.0", "better-sqlite3": "^11.8.1", "child_process": "^1.0.2", "dockerode": "^4.0.4", @@ -22,6 +27,7 @@ "drizzle-orm": "^0.39.3", "express": "^4.21.2", "express-rate-limit": "^7.5.0", + "figlet": "^1.8.0", "password-validator": "^5.3.0", "validator": "^13.12.0" }, diff --git a/src/actions/accounts/addAccount.ts b/src/actions/accounts/addAccount.ts new file mode 100644 index 0000000..9551f0a --- /dev/null +++ b/src/actions/accounts/addAccount.ts @@ -0,0 +1,126 @@ +import { Request, Response } from "express"; +import { containerExec } from "../../utils/docker"; +import { validateEmail, validatePassword } from "../../utils/validators"; +import { accounts } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import fs from "fs/promises"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { updateAccountsCache } from "../../utils/updateAccountsCache"; + +const db = drizzle(process.env.DB_FILE_NAME!); + +export const addAccount = async (req: Request, res: Response): Promise => { + const { email, password, migrate } = req.body; + + if (!validateEmail(email)) { + console.log("[!] Error\nTASK| addAccount\nERR | Invalid email format"); + res.status(400).json({ error: "Invalid email format" }); + return; + } + + let finalPassword = password; + + if (migrate) { + console.log("[*] Task started\nTASK| addAccount (subtask: migrate)\nACC |", email); + try { + const data = await fs.readFile("/app/migrate.txt", "utf8"); + const line = data.split("\n").find(l => l.trim() === email); + if (!line) { + console.log("[!] Error\nTASK| addAccount (subtask: migrate)\nERR | Account not found in migrate.txt\nACC |", email); + res.status(500).json({ error: "Backend error" }); + return; + } else { + const newData = data.replace(line, ""); + await fs.writeFile("/app/migrate.txt", newData); + const output = await containerExec(["setup", "email", "update", email, finalPassword]); + if (/ERROR/i.test(output)) { + console.log("[!] Error\nTASK| addAccount (subtask: migrate)\nERR | Password update failed\nACC |", email); + res.status(500).json({ error: "Migration failed" }); + return; + } else { + await updateAccountsCache(); + const addedAccount = await db + .select() + .from(accounts) + .where(eq(accounts.email, email)) + .limit(1); + if (addedAccount.length > 0) { + console.log("[*] Task completed\nTASK| addAccount (subtask: migrate)\nACC |", email); + res.json({ success: true }); + return; + } else { + console.log("[!] Error\nTASK| addAccount (subtask: migrate)\nERR | Account not found in database\nACC |", email); + res.status(500).json({ error: "Migration failed" }); + return; + } + } + } + } catch (error) { + // the [1] makes it easy to identify where errors with the same message are coming from + console.log("[!] Error\nTASK| addAccount (subtask: migrate)\nERR | [1] Unspecified error\nACC |", email); + res.status(500).json({ error: "Backend error" }); + return; + } + } else { + console.log("[*] Task started\nTASK| addAccount\nACC |", email); + try { + const account = await db + .select() + .from(accounts) + .where(eq(accounts.email, email)) + .limit(1); + if (account.length > 0) { + console.log("[!] Error\nTASK| addAccount\nERR | Account already exists\nACC |", email); + res.status(400).json({ error: "Account already exists" }); + return; + } + } catch (err) { + console.log("[!] Error\nTASK| addAccount\nERR | [2] Unspecified error\nLOGS|", err); + res.status(500).json({ error: "Internal server error" }); + return; + } + } + + if (!finalPassword || !validatePassword(finalPassword)) { + console.log("[!] Error\nTASK| addAccount\nERR | Invalid or weak password\nACC |", email); + res.status(400).json({ error: "Invalid or weak password" }); + return; + } + + try { + const output = await containerExec(["setup", "email", "add", email, finalPassword]); + if (/ERROR/i.test(output)) { + console.log("[!] Error\nTASK| addAccount\nERR | Error during account creation\nACC |", email); + res.status(500).json({ error: "Error during account creation" }); + return; + } + let accountFound = false; + for (let attempt = 0; attempt < 15; attempt++) { + await updateAccountsCache(); + const addedAccount = await db + .select() + .from(accounts) + .where(eq(accounts.email, email)) + .limit(1); + if (addedAccount.length > 0) { + accountFound = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + if (accountFound) { + console.log("[*] Task completed\nTASK| addAccount\nACC |", email); + res.json({ success: true }); + } else { + console.log("[!] Error\nTASK| addAccount\nERR | Timed out waiting for account creation\nACC |", email); + res.status(500).json({ + error: "Check timed out waiting for account creation", + failureReason: "Timed out waiting for account creation", + }); + } + } catch (error) { + console.log("[!] Error\nTASK| addAccount\nERR | [3] Unspecified error\nLOGS|", error); + res.status(500).json({ error: "Backend error" }); + } +}; \ No newline at end of file diff --git a/src/actions/accounts/getUserAccount.ts b/src/actions/accounts/getUserAccount.ts new file mode 100644 index 0000000..3271801 --- /dev/null +++ b/src/actions/accounts/getUserAccount.ts @@ -0,0 +1,37 @@ +import { Request, Response } from "express"; +import { accounts } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import { validateEmail } from "../../utils/validators"; +import { drizzle } from "drizzle-orm/bun-sqlite"; + +const db = drizzle(process.env.DB_FILE_NAME!); + +export const getUserAccount = async (req: Request, res: Response): Promise => { + const { email } = req.body; + console.log("[*] Task started\nTASK| getUserAccount\nACC |", email); + + if (!validateEmail(email)) { + console.log("[!] Error\nTASK| getUserAccount\nERR | Invalid email format\nACC |", email); + res.status(400).json({ error: "Invalid email format" }); + return; + } + + try { + const account = await db + .select() + .from(accounts) + .where(eq(accounts.email, email)) + .limit(1); + + if (account.length > 0) { + console.log("[*] Task completed\nTASK| getUserAccount\nACC |", email); + res.json({ account: account[0] }); + } else { + console.log("[!] Error\nTASK| getUserAccount\nERR | Account not found\nACC |", email); + res.status(404).json({ error: "Account not found" }); + } + } catch (err) { + console.log("[!] Error\nTASK| getUserAccount\nERR | Unspecified error\nACC |", email); + res.status(500).json({ error: (err as Error).message }); + } +}; \ No newline at end of file diff --git a/src/actions/accounts/listAccounts.ts b/src/actions/accounts/listAccounts.ts new file mode 100644 index 0000000..ea35eab --- /dev/null +++ b/src/actions/accounts/listAccounts.ts @@ -0,0 +1,28 @@ +import { Request, Response } from "express"; +import { cacheInfo, accounts } from "../../db/schema"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { updateAccountsCache } from "../../utils/updateAccountsCache"; + +const db = drizzle(process.env.DB_FILE_NAME!); + +export const listAccounts = async (_req: Request, res: Response): Promise => { + console.log("[*] Task started\nTASK| listAccounts"); + + try { + const cacheData = await db.select().from(cacheInfo).limit(1); + const lastUpdated = cacheData[0]?.lastUpdated || 0; + const currentTime = Date.now(); + + if (currentTime - lastUpdated > 30 * 60 * 1000) { + // 30 minutes + await updateAccountsCache(); + } + + const accountsList = await db.select().from(accounts); + console.log("[*] Task completed\nTASK| listAccounts"); + res.json({ accounts: accountsList }); + } catch (err) { + console.log("[!] Error\nTASK| listAccounts\nERR | Unspecified error\nLOG |", (err as Error).message); + res.status(500).json({ error: (err as Error).message }); + } +}; \ No newline at end of file diff --git a/src/actions/accounts/updatePassword.ts b/src/actions/accounts/updatePassword.ts new file mode 100644 index 0000000..76555f4 --- /dev/null +++ b/src/actions/accounts/updatePassword.ts @@ -0,0 +1,28 @@ +import { Request, Response } from "express"; +import { containerExec } from "../../utils/docker"; +import { validateEmail } from "../../utils/validators"; + +export const updatePassword = async (req: Request, res: Response): Promise => { + const { email, password } = req.body; + console.log("[*] Task started\nTASK| updatePassword\nACC |", email); + + if (!validateEmail(email)) { + console.log("[!] Error\nTASK| updatePassword\nERR | Invalid email format\nACC |", email); + res.status(400).json({ error: "Invalid email format" }); + return; + } + + try { + const output = await containerExec(["setup", "email", "update", email, password]); + if (/ERROR/i.test(output)) { + console.log("[!] Error\nTASK| updatePassword\nERR | Password update failed\nACC |", email); + res.status(500).json({ error: "Error during reset" }); + } else { + console.log("[*] Task completed\nTASK| updatePassword\nACC |", email); + res.json({ success: true }); + } + } catch (err) { + console.log("[!] Error\nTASK| updatePassword\nERR | Unspecified error\nACC |", email); + res.status(500).json({ error: (err as Error).message }); + } +}; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 1bde13c..fd3c340 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,352 +1,45 @@ -import express from "express" -import fs from "fs/promises" -import rateLimit from "express-rate-limit" -import Docker from "dockerode" -import validator from "validator" -import PasswordValidator from "password-validator" -import { accounts, cacheInfo } from "./db/schema" -import { eq } from "drizzle-orm" -import type { Request, Response } from "express" -import { drizzle } from 'drizzle-orm/bun-sqlite'; +import express from "express"; +import figlet from "figlet"; +import { createLimiter, loadRateLimitConfig } from "./utils/rateLimit"; +import { listAccounts } from "./actions/accounts/listAccounts"; +import { getUserAccount } from "./actions/accounts/getUserAccount"; +import { updatePassword } from "./actions/accounts/updatePassword"; +import { addAccount } from "./actions/accounts/addAccount"; + +const app = express(); +app.use(express.json()); interface RateLimitOptions { - windowMs: number - limit: number - message?: string + windowMs: number; + limit: number; + message?: string; } -interface RateLimitConfig { - [endpoint: string]: RateLimitOptions -} +let rateLimitConfig = {}; +(async () => { + rateLimitConfig = await loadRateLimitConfig(); + Object.entries(rateLimitConfig).forEach(([route, options]) => { + app.use(route, createLimiter(options)); + }); +})(); -interface Account { - email: string - used: string - capacity: string - percentage: string -} +app.get("/accounts/list", listAccounts); +app.post("/accounts/user", getUserAccount); +app.post("/accounts/update/password", updatePassword); +app.post("/accounts/add", addAccount); -const docker = new Docker({ socketPath: "/var/run/docker.sock" }) -const app = express() -const db = drizzle(process.env.DB_FILE_NAME!); -const container = docker.getContainer("mailserver"); -app.use(express.json()) - -let rateLimitConfig: RateLimitConfig = {} -;(async () => { - try { - const data = await fs.readFile("/app/ratelimit.json", "utf8") - rateLimitConfig = JSON.parse(data) - } catch (err) { - console.error("Error loading rate limit config:", err) - } -})() - -const createLimiter = (options: RateLimitOptions) => - rateLimit({ - windowMs: options.windowMs, - limit: options.limit, - message: options.message || "Too many requests, please try again later.", - }) - -Object.entries(rateLimitConfig).forEach(([route, options]) => { - app.use(route, createLimiter(options)) -}) - -const validateEmail = (email: string): boolean => validator.isEmail(email) - -const passwordSchema = new PasswordValidator() -passwordSchema.is().min(8).is().max(64).has().letters().has().digits().has().not().spaces() - -const listAccountsFromDocker = async (): Promise => { - return new Promise((resolve, reject) => { - const container = docker.getContainer("mailserver") - container.exec( - { - Cmd: ["setup", "email", "list"], - AttachStdout: true, - AttachStderr: true, - }, - (err, exec) => { - if (err || !exec) return reject(err || new Error("Exec is undefined")) - exec.start({}, (err, stream) => { - if (err || !stream) return reject(err || new Error("Exec stream is undefined")) - let output = "" - stream.on("data", (chunk: Buffer) => (output += chunk.toString())) - stream.on("end", () => { - const regex = /\*\s*(\S+)\s*\(\s*([^\/]+?)\s*\/\s*([^)]+?)\s*\)\s*\[(\d+)%]/g - const accounts: Account[] = [...output.matchAll(regex)].map((match) => ({ - email: match[1], - used: match[2].trim() === "~" ? "Unlimited" : match[2].trim(), - capacity: match[3].trim() === "~" ? "Unlimited" : match[3].trim(), - percentage: match[4], - })) - resolve(accounts) - }) - }) - }, - ) - }) -} - -const updateAccountsCache = async () => { - const dockerAccounts = await listAccountsFromDocker(); - await db.delete(accounts); - if (dockerAccounts.length > 0) { - await db.insert(accounts).values(dockerAccounts); - } - await db - .insert(cacheInfo) - .values({ lastUpdated: Date.now() }) - .onConflictDoUpdate({ target: cacheInfo.id, set: { lastUpdated: Date.now() } }); -}; - -app.get("/accounts/list", async (_req, res) => { - try { - const cacheData = await db.select().from(cacheInfo).limit(1) - const lastUpdated = cacheData[0]?.lastUpdated || 0 - const currentTime = Date.now() - - if (currentTime - lastUpdated > 30 * 60 * 1000) { - // 30 minutes - await updateAccountsCache() - } - - const accountsList = await db.select().from(accounts) - res.json({ accounts: accountsList }) - } catch (err) { - res.status(500).json({ error: (err as Error).message }) - } -}) - -app.post("/accounts/user", async (req: Request, res: Response): Promise => { - const { email } = req.body; - if (!validateEmail(email)) { - res.status(400).json({ error: "Invalid email format" }); - return; - } - - try { - const account = await db - .select() - .from(accounts) - .where(eq(accounts.email, email)) - .limit(1); - - if (account.length > 0) { - res.json({ account: account[0] }); +const PORT = 3000; +app.listen(PORT, () => { + figlet('mail-connect', (err, data) => { + if (err) { + console.log('mail-connect'); + console.log('Version: 0.1.0'); + console.log(`API listening on port ${PORT}\n`); + console.dir("[!] " + err); } else { - res.status(404).json({ error: "Account not found" }); + console.log(data); + console.log('Version: 0.1.0'); + console.log(`API listening on port ${PORT}\n`); } - } catch (err) { - res.status(500).json({ error: (err as Error).message }); - } -}); - -app.post("/accounts/update/password", async (req: Request, res: Response): Promise => { - const { email, password } = req.body; - - if (!validateEmail(email)) { - console.log("Error updating password: Invalid email format"); - res.status(400).json({ error: "Invalid email format" }); - return; - } - - const exec = await container.exec({ - Cmd: ["setup", "email", "update", email, password], - AttachStdout: true, - AttachStderr: true, }); - const stream = await new Promise((resolve, reject) => { - exec.start({}, (err, stream) => { - if (err || !stream) { - reject(err || new Error("Exec stream is undefined")); - } else { - resolve(stream); - } - }); - }); - let output = ""; - stream.on("data", (chunk: Buffer) => { - output += chunk.toString(); - }); - await new Promise((resolve) => stream.on("end", resolve)); - console.log("Docker output (update password):\n", output); - // detect errors - if (/ERROR/i.test(output)) { - console.log(`Error during migration: Password reset failed`); - res.status(500).json({ error: "Error during reset" }); - return; - } else { - console.log(`Reset password for account: ${email}`); - res.json({ success: true }); - return; - } -}); - -// TODO: The wait upon account creation needs to be more adaptive -app.post("/accounts/add", async (req: Request, res: Response): Promise => { - const { email, password, migrate } = req.body; - let failureReason = ""; - - if (!validateEmail(email)) { - console.log("Error adding account: Invalid email format"); - res.status(400).json({ error: "Invalid email format" }); - return; - } - - let finalPassword = password; - - if (migrate) { - try { - const data = await fs.readFile("/app/migrate.txt", "utf8"); - const line = data.split("\n").find(l => l.trim() === email); - if (!line) { - failureReason = "Account not eligible"; - console.log(`Error adding account (migrate): ${failureReason}`); - res.status(500).json({ error: "Backend error" }); - return; - } else { - const newData = data.replace(line, ""); - await fs.writeFile("/app/migrate.txt", newData); - const exec = await container.exec({ - Cmd: ["setup", "email", "update", email, finalPassword], - AttachStdout: true, - AttachStderr: true, - }); - const stream = await new Promise((resolve, reject) => { - exec.start({}, (err, stream) => { - if (err || !stream) { - reject(err || new Error("Exec stream is undefined")); - } else { - resolve(stream); - } - }); - }); - let output = ""; - stream.on("data", (chunk: Buffer) => { - output += chunk.toString(); - }); - await new Promise((resolve) => stream.on("end", resolve)); - console.log("Docker output (migrate update):\n", output); - // detect errors - if (/ERROR/i.test(output)) { - failureReason = "Migration failed"; - console.log(`Error during migration: ${failureReason}`); - res.status(500).json({ error: failureReason }); - return; - } else { - // force refresh - await updateAccountsCache(); - const addedAccount = await db - .select() - .from(accounts) - .where(eq(accounts.email, email)) - .limit(1); - if (addedAccount.length > 0) { - console.log(`Added account (via migrate): ${email}`); - res.json({ success: true }); - return; - } else { - failureReason = "Migration failed"; - console.log(`Failed to migrate account: ${failureReason}`); - res.status(500).json({ error: failureReason }); - return; - } - } - } - } catch (error) { - failureReason = "Backend error"; - console.log(`Error adding account (migrate branch): ${failureReason}`, error); - res.status(500).json({ error: failureReason }); - return; - } - } else { - try { - const account = await db - .select() - .from(accounts) - .where(eq(accounts.email, email)) - .limit(1); - if (account.length > 0) { - console.log(`Account already exists: ${email}`); - res.status(400).json({ error: "Account already exists" }); - return; - } - } catch (err) { - console.log(`Error checking user: ${err}`); - res.status(500).json({ error: "Internal server error" }); - return; - } - } - - if (!finalPassword || !passwordSchema.validate(finalPassword)) { - failureReason = "Invalid or weak password"; - console.log(`Failed to add account: ${failureReason}`); - res.status(400).json({ error: failureReason }); - return; - } - - // Non-migrate account creation route - try { - const exec = await container.exec({ - Cmd: ["setup", "email", "add", email, finalPassword], - AttachStdout: true, - AttachStderr: true, - }); - - const stream = await new Promise((resolve, reject) => { - exec.start({}, (err, stream) => { - if (err || !stream) { - failureReason = "Failed to start Docker exec"; - console.log(`Failed to add account: ${failureReason}`, err); - reject(err || new Error("Exec stream is undefined")); - } else { - resolve(stream); - } - }); - }); - - let output = ""; - stream.on("data", (chunk: Buffer) => { - output += chunk.toString(); - }); - await new Promise((resolve) => stream.on("end", resolve)); - - // poll db - let accountFound = false; - for (let attempt = 0; attempt < 15; attempt++) { - await updateAccountsCache(); - const addedAccount = await db - .select() - .from(accounts) - .where(eq(accounts.email, email)) - .limit(1); - if (addedAccount.length > 0) { - accountFound = true; - break; - } - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - if (accountFound) { - console.log(`Added account: ${email}`); - res.json({ success: true }); - } else { - failureReason = "Timed out waiting for account creation"; - console.log(`Failed to add account: ${failureReason}`); - res.status(500).json({ - error: "Check timed out waiting for account creation", - failureReason, - }); - } - } catch (error) { - failureReason = "Error executing Docker command"; - console.log(`Failed to add account: ${failureReason}`, error); - res.status(500).json({ error: "Backend error" }); - } -}); - -const PORT = 3000 -app.listen(PORT, () => console.log(`API listening on port ${PORT}`)) - +}); \ No newline at end of file diff --git a/src/utils/docker.ts b/src/utils/docker.ts new file mode 100644 index 0000000..4042531 --- /dev/null +++ b/src/utils/docker.ts @@ -0,0 +1,67 @@ +import Docker from "dockerode"; + +const docker = new Docker({ socketPath: "/var/run/docker.sock" }); +const container = docker.getContainer("mailserver"); + +export interface Account { + email: string; + used: string; + capacity: string; + percentage: string; + id?: number; +} + +export const listAccountsFromDocker = async (): Promise => { + return new Promise((resolve, reject) => { + container.exec( + { + Cmd: ["setup", "email", "list"], + AttachStdout: true, + AttachStderr: true, + }, + (err, exec) => { + if (err || !exec) return reject(err || new Error("Exec is undefined")); + exec.start({}, (err, stream) => { + if (err || !stream) return reject(err || new Error("Exec stream is undefined")); + let output = ""; + stream.on("data", (chunk: Buffer) => (output += chunk.toString())); + stream.on("end", () => { + const regex = /\*\s*(\S+)\s*\(\s*([^\/]+?)\s*\/\s*([^)]+?)\s*\)\s*\[(\d+)%]/g; + const accounts = [...output.matchAll(regex)].map((match) => ({ + email: match[1], + used: match[2].trim() === "~" ? "Unlimited" : match[2].trim(), + capacity: match[3].trim() === "~" ? "Unlimited" : match[3].trim(), + percentage: match[4], + })); + resolve(accounts); + }); + }); + } + ); + }); +}; + +export const getExecStream = (exec: Docker.Exec): Promise => { + return new Promise((resolve, reject) => { + exec.start({}, (err, stream) => { + if (err || !stream) { + reject(err || new Error("Exec stream is undefined")); + } else { + let output = ""; + stream.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + stream.on("end", () => resolve(output)); + } + }); + }); +}; + +export const containerExec = async (cmd: string[]) => { + const exec = await container.exec({ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }); + return getExecStream(exec); +}; \ No newline at end of file diff --git a/src/utils/rateLimit.ts b/src/utils/rateLimit.ts new file mode 100644 index 0000000..63c45c9 --- /dev/null +++ b/src/utils/rateLimit.ts @@ -0,0 +1,30 @@ +import rateLimit from "express-rate-limit"; +import fs from "fs/promises"; + +interface RateLimitOptions { + windowMs: number; + limit: number; + message?: string; +} + +interface RateLimitConfig { + [endpoint: string]: RateLimitOptions; +} + +export const loadRateLimitConfig = async (): Promise => { + try { + await fs.access(`${process.env.MAILCONNECT_ROOT_DIR}/ratelimit.json`); + const data = await fs.readFile(`${process.env.MAILCONNECT_ROOT_DIR}/ratelimit.json`, "utf8"); + return JSON.parse(data); + } catch (err) { + console.error("[!] Error loading ratelimit config:\n", err); + process.exit(1); + } +}; + +export const createLimiter = (options: RateLimitOptions) => + rateLimit({ + windowMs: options.windowMs, + limit: options.limit, + message: options.message || "Too many requests, please try again later.", + }); \ No newline at end of file diff --git a/src/utils/updateAccountsCache.ts b/src/utils/updateAccountsCache.ts new file mode 100644 index 0000000..d571ce2 --- /dev/null +++ b/src/utils/updateAccountsCache.ts @@ -0,0 +1,17 @@ +import { listAccountsFromDocker } from "./docker"; +import { accounts, cacheInfo } from "../db/schema"; +import { drizzle } from "drizzle-orm/bun-sqlite"; + +const db = drizzle(process.env.DB_FILE_NAME!); + +export const updateAccountsCache = async () => { + const dockerAccounts = await listAccountsFromDocker(); + await db.delete(accounts); + if (dockerAccounts.length > 0) { + await db.insert(accounts).values(dockerAccounts); + } + await db + .insert(cacheInfo) + .values({ lastUpdated: Date.now() }) + .onConflictDoUpdate({ target: cacheInfo.id, set: { lastUpdated: Date.now() } }); +}; \ No newline at end of file diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..04ed3a2 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,9 @@ +import validator from "validator"; +import PasswordValidator from "password-validator"; + +export const validateEmail = (email: string): boolean => validator.isEmail(email); + +export const passwordSchema = new PasswordValidator(); +passwordSchema.is().min(8).is().max(64).has().letters().has().digits().has().not().spaces(); + +export const validatePassword = (password: string): boolean => passwordSchema.validate(password); \ No newline at end of file