diff --git a/.env.example b/.env.example index 01df051..6f1c791 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ APP_PORT=3001 APP_ORIGINS=http://localhost:5173 APP_EXTERNAL_BASE_URL=http://localhost:5173 APP_DATA_PATH=../../data/app.db +WEB_DIST_PATH=../web/dist MAILSERVER_CONTAINER=mailserver DOCKER_BINARY=docker MAILSERVER_TIMEOUT_MS=10000 diff --git a/README.md b/README.md index a98e143..b748fad 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Security choices here: - Bun installed - Docker available locally - A running Docker Mailserver container +- The API process must be allowed to access Docker ### Setup @@ -184,6 +185,36 @@ bun run dev:web 6. Open `http://localhost:5173`. +### Production-style local run + +If you want the built frontend to be served by the API itself: + +```bash +bun run build +bun run dev:api +``` + +Then open `http://localhost:3001`. + +The API serves files from `apps/web/dist` by default, configurable with `WEB_DIST_PATH`. + +### Docker permission note + +If you see: + +```text +permission denied while trying to connect to the docker API at unix:///var/run/docker.sock +``` + +the app itself is fine, but the API process cannot talk to Docker. + +Common fixes: + +- Run the API with a user that belongs to the `docker` group +- If the API runs in a container, mount `/var/run/docker.sock:/var/run/docker.sock` +- Make sure the Docker daemon is actually running +- Make sure `MAILSERVER_CONTAINER` matches the real Docker Mailserver container name + ## Notes and assumptions - This panel assumes Docker Mailserver is using the file-based account provisioner and supports the standard `setup email` commands. diff --git a/apps/api/src/docker/runner.ts b/apps/api/src/docker/runner.ts index 39775a7..7ae7c7b 100644 --- a/apps/api/src/docker/runner.ts +++ b/apps/api/src/docker/runner.ts @@ -7,6 +7,37 @@ type DockerResult = { stderr: string; }; +const toDockerError = (stderr: string, stdout: string): HttpError => { + const details = (stderr || stdout).trim(); + const normalized = details.toLowerCase(); + + if ( + normalized.includes("permission denied while trying to connect to the docker api") || + normalized.includes("permission denied") && normalized.includes("docker.sock") + ) { + return new HttpError( + 502, + "The API process cannot access Docker. Grant access to /var/run/docker.sock, run the API with a user in the docker group, or mount the Docker socket into the API container." + ); + } + + if ( + normalized.includes("cannot connect to the docker daemon") || + normalized.includes("is the docker daemon running") + ) { + return new HttpError(502, "Docker is not reachable. Check that the Docker daemon is running and accessible to the API process."); + } + + if ( + normalized.includes("no such container") || + normalized.includes("no such object") + ) { + return new HttpError(502, `Docker Mailserver container '${config.mailserverContainer}' was not found.`); + } + + return new HttpError(502, details || "Docker Mailserver command failed."); +}; + export const runDocker = async ( args: string[], input?: string @@ -48,7 +79,7 @@ export const runDocker = async ( }); if (exitCode !== 0) { - throw new HttpError(502, stderr.trim() || stdout.trim() || "Docker Mailserver command failed."); + throw toDockerError(stderr, stdout); } return { @@ -69,4 +100,3 @@ export const runDocker = async ( clearTimeout(timeout); } }; - diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 29c985c..2ef59d3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,6 +3,7 @@ import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, se import { cleanupDatabase } from "./lib/db"; import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http"; import { config } from "./lib/config"; +import { serveFrontendBuild } from "./lib/frontend"; import { createUserSchema, loginSchema, @@ -156,6 +157,16 @@ app.post( }) ); +app.get( + "/", + route(async ({ request }) => serveFrontendBuild(request)) +); + +app.get( + "/*", + route(async ({ request }) => serveFrontendBuild(request)) +); + app.listen({ hostname: config.host, port: config.port diff --git a/apps/api/src/lib/config.ts b/apps/api/src/lib/config.ts index 0aa4f62..703155a 100644 --- a/apps/api/src/lib/config.ts +++ b/apps/api/src/lib/config.ts @@ -32,5 +32,6 @@ export const config = { appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "", appAdminPassword: env.APP_ADMIN_PASSWORD ?? "", appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "no-reply@example.com", + webDistPath: resolve(process.cwd(), env.WEB_DIST_PATH ?? "../web/dist"), isProduction: (env.NODE_ENV ?? "development") === "production" }; diff --git a/apps/api/src/lib/frontend.ts b/apps/api/src/lib/frontend.ts new file mode 100644 index 0000000..13b2249 --- /dev/null +++ b/apps/api/src/lib/frontend.ts @@ -0,0 +1,64 @@ +import { resolve, sep } from "node:path"; +import { config } from "./config"; + +const frontendRoot = resolve(config.webDistPath); +const indexFile = Bun.file(resolve(frontendRoot, "index.html")); + +const noCacheHeaders = { + "Cache-Control": "no-cache" +}; + +const assetCacheHeaders = { + "Cache-Control": "public, max-age=31536000, immutable" +}; + +const toSafePath = (pathname: string): string | null => { + const cleaned = pathname.replace(/^\/+/, ""); + const absolutePath = resolve(frontendRoot, cleaned); + + if (absolutePath !== frontendRoot && !absolutePath.startsWith(`${frontendRoot}${sep}`)) { + return null; + } + + return absolutePath; +}; + +const fileResponse = (file: Bun.BunFile, headers?: HeadersInit): Response => + new Response(file, { + status: 200, + headers + }); + +export const serveFrontendBuild = async (request: Request): Promise => { + const { pathname } = new URL(request.url); + + if (pathname === "/api" || pathname.startsWith("/api/")) { + return new Response("Not found", { status: 404 }); + } + + if (!(await indexFile.exists())) { + return new Response("Frontend build not found. Run `bun run build` first.", { + status: 503, + headers: { + "content-type": "text/plain; charset=utf-8" + } + }); + } + + if (pathname !== "/") { + const safePath = toSafePath(pathname); + if (!safePath) { + return new Response("Not found", { status: 404 }); + } + + const assetFile = Bun.file(safePath); + if (await assetFile.exists()) { + return fileResponse( + assetFile, + pathname.startsWith("/assets/") ? assetCacheHeaders : noCacheHeaders + ); + } + } + + return fileResponse(indexFile, noCacheHeaders); +}; diff --git a/not.md b/not.md new file mode 100644 index 0000000..259234c --- /dev/null +++ b/not.md @@ -0,0 +1,13 @@ +Fait moi un prompt pour mon AI qui doit faire sa: (en anglais btw): + +Code moi un backend-frontend (Frontend: choix du framework): Backend ElysiaJS (avec Bun) +Le back utilise docker en CLI pour communiquer avec le docker mail-server + +Le projet est un front pour gérer les utilisateurs emails d'un serveur dockermail-server. + +Créer, ajouter, modifier, supprimer, lister des utilisateurs + +Modifier le mot de passe avec un one-time link (à envoyer à l'utilisateur pour politique sécurité) + +Le projet doit être KISS (Keep It Simple Stupid). Simple, pas de complexité nécessaire, le front-end doit être design sobre +La codebase doit être simple, propre. Pour pouvoir y ajoutez des futurs fonctionalité \ No newline at end of file