>
This commit is contained in:
@@ -4,6 +4,7 @@ APP_PORT=3001
|
|||||||
APP_ORIGINS=http://localhost:5173
|
APP_ORIGINS=http://localhost:5173
|
||||||
APP_EXTERNAL_BASE_URL=http://localhost:5173
|
APP_EXTERNAL_BASE_URL=http://localhost:5173
|
||||||
APP_DATA_PATH=../../data/app.db
|
APP_DATA_PATH=../../data/app.db
|
||||||
|
WEB_DIST_PATH=../web/dist
|
||||||
MAILSERVER_CONTAINER=mailserver
|
MAILSERVER_CONTAINER=mailserver
|
||||||
DOCKER_BINARY=docker
|
DOCKER_BINARY=docker
|
||||||
MAILSERVER_TIMEOUT_MS=10000
|
MAILSERVER_TIMEOUT_MS=10000
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ Security choices here:
|
|||||||
- Bun installed
|
- Bun installed
|
||||||
- Docker available locally
|
- Docker available locally
|
||||||
- A running Docker Mailserver container
|
- A running Docker Mailserver container
|
||||||
|
- The API process must be allowed to access Docker
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@@ -184,6 +185,36 @@ bun run dev:web
|
|||||||
|
|
||||||
6. Open `http://localhost:5173`.
|
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
|
## Notes and assumptions
|
||||||
|
|
||||||
- This panel assumes Docker Mailserver is using the file-based account provisioner and supports the standard `setup email` commands.
|
- This panel assumes Docker Mailserver is using the file-based account provisioner and supports the standard `setup email` commands.
|
||||||
|
|||||||
@@ -7,6 +7,37 @@ type DockerResult = {
|
|||||||
stderr: string;
|
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 (
|
export const runDocker = async (
|
||||||
args: string[],
|
args: string[],
|
||||||
input?: string
|
input?: string
|
||||||
@@ -48,7 +79,7 @@ export const runDocker = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
throw new HttpError(502, stderr.trim() || stdout.trim() || "Docker Mailserver command failed.");
|
throw toDockerError(stderr, stdout);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -69,4 +100,3 @@ export const runDocker = async (
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, se
|
|||||||
import { cleanupDatabase } from "./lib/db";
|
import { cleanupDatabase } from "./lib/db";
|
||||||
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
|
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
|
||||||
import { config } from "./lib/config";
|
import { config } from "./lib/config";
|
||||||
|
import { serveFrontendBuild } from "./lib/frontend";
|
||||||
import {
|
import {
|
||||||
createUserSchema,
|
createUserSchema,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
@@ -156,6 +157,16 @@ app.post(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/",
|
||||||
|
route(async ({ request }) => serveFrontendBuild(request))
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/*",
|
||||||
|
route(async ({ request }) => serveFrontendBuild(request))
|
||||||
|
);
|
||||||
|
|
||||||
app.listen({
|
app.listen({
|
||||||
hostname: config.host,
|
hostname: config.host,
|
||||||
port: config.port
|
port: config.port
|
||||||
|
|||||||
@@ -32,5 +32,6 @@ export const config = {
|
|||||||
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "",
|
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "",
|
||||||
appAdminPassword: env.APP_ADMIN_PASSWORD ?? "",
|
appAdminPassword: env.APP_ADMIN_PASSWORD ?? "",
|
||||||
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
|
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
|
||||||
|
webDistPath: resolve(process.cwd(), env.WEB_DIST_PATH ?? "../web/dist"),
|
||||||
isProduction: (env.NODE_ENV ?? "development") === "production"
|
isProduction: (env.NODE_ENV ?? "development") === "production"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<Response> => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
@@ -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é
|
||||||
Reference in New Issue
Block a user