upd: split files for easier contributing, cleaned up code, add bump ci
All checks were successful
Bump Dependencies / update-dependencies (push) Successful in 19s
Build and Push Nightly CI Image / build_and_push (push) Successful in 1m43s
Build and Push Docker Image / build_and_push (push) Successful in 4s

This commit is contained in:
Aidan 2025-02-25 01:06:37 -05:00
parent 1ecd4f4629
commit fdf37ac9b1
14 changed files with 431 additions and 347 deletions

View File

@ -1,2 +1,3 @@
MIGRATE_TXT="migrate.txt" MIGRATE_TXT="migrate.txt"
DB_FILE_NAME="db.sqlite3" DB_FILE_NAME="db.sqlite3"
MAILCONNECT_ROOT_DIR="/app"

40
.gitea/workflows/bump.yml Normal file
View File

@ -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

View File

@ -15,7 +15,7 @@ We provide an extendable API which interacts with the `setup` utility via a Dock
## What this API is NOT ## 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**. 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,9 +45,11 @@ All features marked with an **E** are extended features, and are not a part of t
```bash ```bash
touch migrate.txt # put emails (one per line) which already exist on the server which users can claim 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 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** 4. **Build and run the container**
```bash ```bash

View File

@ -1,6 +1,6 @@
services: services:
mail-connect: mail-connect:
build: . image: "git.pontusmail.org/librecloud/web:latest"
container_name: mail-connect container_name: mail-connect
env_file: ".env" env_file: ".env"
ports: ports:

View File

@ -2,6 +2,10 @@
"name": "mail-connect", "name": "mail-connect",
"module": "src/server.ts", "module": "src/server.ts",
"type": "module", "type": "module",
"scripts": {
"start": "bun src/server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/dockerode": "^3.3.35", "@types/dockerode": "^3.3.35",
@ -15,6 +19,7 @@
}, },
"dependencies": { "dependencies": {
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/figlet": "^1.7.0",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"child_process": "^1.0.2", "child_process": "^1.0.2",
"dockerode": "^4.0.4", "dockerode": "^4.0.4",
@ -22,6 +27,7 @@
"drizzle-orm": "^0.39.3", "drizzle-orm": "^0.39.3",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"figlet": "^1.8.0",
"password-validator": "^5.3.0", "password-validator": "^5.3.0",
"validator": "^13.12.0" "validator": "^13.12.0"
}, },

View File

@ -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<void> => {
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" });
}
};

View File

@ -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<void> => {
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 });
}
};

View File

@ -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<void> => {
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 });
}
};

View File

@ -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<void> => {
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 });
}
};

View File

@ -1,352 +1,45 @@
import express from "express" import express from "express";
import fs from "fs/promises" import figlet from "figlet";
import rateLimit from "express-rate-limit" import { createLimiter, loadRateLimitConfig } from "./utils/rateLimit";
import Docker from "dockerode" import { listAccounts } from "./actions/accounts/listAccounts";
import validator from "validator" import { getUserAccount } from "./actions/accounts/getUserAccount";
import PasswordValidator from "password-validator" import { updatePassword } from "./actions/accounts/updatePassword";
import { accounts, cacheInfo } from "./db/schema" import { addAccount } from "./actions/accounts/addAccount";
import { eq } from "drizzle-orm"
import type { Request, Response } from "express" const app = express();
import { drizzle } from 'drizzle-orm/bun-sqlite'; app.use(express.json());
interface RateLimitOptions { interface RateLimitOptions {
windowMs: number windowMs: number;
limit: number limit: number;
message?: string message?: string;
} }
interface RateLimitConfig { let rateLimitConfig = {};
[endpoint: string]: RateLimitOptions (async () => {
} rateLimitConfig = await loadRateLimitConfig();
interface Account {
email: string
used: string
capacity: string
percentage: string
}
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]) => { Object.entries(rateLimitConfig).forEach(([route, options]) => {
app.use(route, createLimiter(options)) app.use(route, createLimiter(<RateLimitOptions>options));
}) });
})();
const validateEmail = (email: string): boolean => validator.isEmail(email) app.get("/accounts/list", listAccounts);
app.post("/accounts/user", getUserAccount);
app.post("/accounts/update/password", updatePassword);
app.post("/accounts/add", addAccount);
const passwordSchema = new PasswordValidator() const PORT = 3000;
passwordSchema.is().min(8).is().max(64).has().letters().has().digits().has().not().spaces() app.listen(PORT, () => {
figlet('mail-connect', (err, data) => {
const listAccountsFromDocker = async (): Promise<Account[]> => { if (err) {
return new Promise((resolve, reject) => { console.log('mail-connect');
const container = docker.getContainer("mailserver") console.log('Version: 0.1.0');
container.exec( console.log(`API listening on port ${PORT}\n`);
{ console.dir("[!] " + err);
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<void> => {
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] });
} else { } else {
res.status(404).json({ error: "Account not found" }); console.log(data);
} console.log('Version: 0.1.0');
} catch (err) { console.log(`API listening on port ${PORT}\n`);
res.status(500).json({ error: (err as Error).message });
}
});
app.post("/accounts/update/password", async (req: Request, res: Response): Promise<void> => {
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<NodeJS.ReadableStream>((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<void>((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<void> => {
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<NodeJS.ReadableStream>((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<void>((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<NodeJS.ReadableStream>((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<void>((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}`))

67
src/utils/docker.ts Normal file
View File

@ -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<Account[]> => {
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<string> => {
return new Promise<string>((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);
};

30
src/utils/rateLimit.ts Normal file
View File

@ -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<RateLimitConfig> => {
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.",
});

View File

@ -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() } });
};

9
src/utils/validators.ts Normal file
View File

@ -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 => <boolean>passwordSchema.validate(password);