commit 3c0479faf00ba6b1813d4de5d2906312dfd671f3 Author: Rutra Date: Mon Oct 12 14:25:22 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d8b3702 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +**/node_modules +**/.DS_Store + +web/dist +web/.angular + +api/build +api/tmp +api/public +api/.env +api/.env.* +!api/.env.example + +dev-srv +dev-backups +deploy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..364b9d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Local backups created while testing (BACKUP_DIR=../dev-backups) +dev-backups/ + +.DS_Store +.vscode/ +.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e11930 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Big Brother: API AdonisJS + interface Angular dans une seule image. + +# 1. Interface Angular +FROM node:24-alpine AS web +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npx ng build + +# 2. API AdonisJS (compilée dans /api/build) +FROM node:24-alpine AS api +RUN apk add --no-cache python3 make g++ +WORKDIR /api +COPY api/package.json api/package-lock.json ./ +RUN npm ci +COPY api/ ./ +RUN node ace build + +# 3. Image finale +FROM node:24-alpine + +# git (mises à jour des projets), client Docker + compose (actions sur les +# conteneurs), tar (sauvegardes), tzdata (fuseau horaire de TZ) +RUN apk add --no-cache git docker-cli docker-cli-compose tar tzdata \ + && git config --system --add safe.directory '*' + +WORKDIR /app +COPY --from=api /api/build ./ +RUN apk add --no-cache --virtual .build python3 make g++ \ + && npm ci --omit=dev \ + && apk del .build +COPY --from=web /web/dist/web/browser ./public + +ENV NODE_ENV=production +EXPOSE 3333 + +# Les migrations SQLite passent à chaque démarrage (rien à faire si la base est à jour). +CMD ["sh", "-c", "node ace migration:run --force && node bin/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f315a8 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# Big Brother + +Tableau de bord web pour gérer et surveiller le VPS : serveur, conteneurs, sites, projets, Caddy et sauvegardes. + +- **API** : AdonisJS 7 (`api/`), base SQLite. +- **Interface** : Angular 22 (`web/`), servie par l'API (un seul conteneur, un seul domaine). + +> « Big Brother » est un nom provisoire. Pour le changer, il suffit de modifier `container_name` dans le `docker-compose.yml`, le bloc Caddy et `APP_URL`. + +## Fonctionnalités + +| Page | Contenu | +|---|---| +| Vue d'ensemble | Processeur, mémoire, disque, réseau, charge, durée depuis le démarrage. Graphiques en direct (toutes les 5 s) et historique sur 1 h, 24 h, 7 j et 30 j. État des conteneurs, des sites et des sauvegardes, dernières actions. | +| Conteneurs | Tous les conteneurs avec leur état, CPU et mémoire. Alerte quand un port est publié sur Internet (seul Caddy devrait l'être). Logs en direct (filtre, pause, téléchargement). Démarrer, arrêter, redémarrer. | +| Sites | Les domaines du Caddyfile, vérifiés chaque minute : état, temps de réponse, disponibilité sur 24 h et 7 j, expiration du certificat HTTPS, incidents. | +| Projets | Les projets de `/srv` (même organisation que la procédure). Pour chacun : dépôt git (version en ligne, nouveautés), mise à jour (`git pull` puis recréation des conteneurs), retour à un ancien commit, redémarrage, arrêt, mise à jour des images. **Création de projet** Node, Adonis, WordPress ou site statique : dossier, clone, `.env`, base MariaDB dédiée, `docker-compose.yml` (templates de la procédure), démarrage et bloc Caddy. | +| Caddy | Éditeur du Caddyfile. Si Caddy refuse la nouvelle configuration, l'ancien fichier est remis en place automatiquement. Historique des 50 dernières versions, restaurables. | +| Sauvegardes | Export de toutes les bases MariaDB et archive de `/srv` (sans `node_modules`). Sauvegarde automatique quotidienne en option, téléchargement depuis la page. | +| Journal | Toutes les actions et connexions, avec la sortie des commandes. | + +## Sécurité + +Le conteneur a accès au socket Docker et à `/srv` en écriture : **c'est l'équivalent d'un accès root au serveur**. Les protections en place : + +- **Comptes** : aucune page d'inscription. Les comptes se créent uniquement en ligne de commande sur le VPS. +- **Connexion** : + - mot de passe d'au moins 12 caractères ; + - code de double authentification (TOTP), refusé s'il a déjà servi ; + - 5 essais ratés par IP, puis blocage pendant 15 minutes. +- **Session** : cookies `httpOnly`, `Secure` et `SameSite=Strict`, session de 12 h, protection CSRF. +- **En-têtes** : Content-Security-Policy stricte (aucun script externe ni en ligne), HSTS, `X-Frame-Options: DENY`. +- **Réseau** : aucun port publié, l'accès passe uniquement par Caddy. Le secret 2FA est chiffré avec `APP_KEY`. +- **Traçabilité** : chaque action est enregistrée dans le journal (qui, quand, depuis quelle IP, avec la sortie). + +## Mise en ligne sur le VPS + +Le projet suit la procédure : un dossier contenant `docker-compose.yml`, avec le dépôt cloné dans `project/`. + +1. Créer le dépôt sur `git.nucleus-ludus.fr` et y pousser ce dossier. + +2. Sur le VPS : + + ```bash + mkdir -p /srv/fullstack/big-brother /root/backups + cd /srv/fullstack/big-brother + git clone https://git.nucleus-ludus.fr//big-brother.git project + cp project/deploy/docker-compose.yml . + cp project/deploy/.env.example .env + nano .env + ``` + + Dans `.env`, remplir au minimum : + - `APP_KEY` : générer la valeur avec `openssl rand -base64 24` ; + - `APP_URL` ; + - `BACKUP_DAILY_AT`. + + Vérifier aussi `CADDY_CONTAINER` et `MARIADB_CONTAINER` : ce sont les noms de tes conteneurs Caddy et MariaDB. + +3. Construire et lancer : + + ```bash + docker compose up -d --build + docker compose logs -f + ``` + +4. Créer ton compte. La commande affiche un QR code à scanner avec ton application de double authentification (Aegis, 2FAS, Google Authenticator…) : + + ```bash + docker exec -it big-brother node ace user:create + ``` + +5. Ajouter le bloc à `/srv/caddy/Caddyfile` (voir `deploy/Caddyfile.block`), puis recharger Caddy : + + ```caddyfile + big-brother.nucleus-ludus.fr { + reverse_proxy big-brother:3333 + } + ``` + + ```bash + docker exec caddy caddy reload --config /etc/caddy/Caddyfile + ``` + +### Ce que monte le `docker-compose.yml` + +| Volume | Rôle | +|---|---| +| `./data:/data` | Base SQLite (comptes, historique, journal) | +| `/var/run/docker.sock` | Conteneurs, logs, actions, rechargement de Caddy, MariaDB | +| `/srv:/srv` | Projets et Caddyfile, **au même chemin** que sur le VPS (nécessaire pour `docker compose`) | +| `/proc:/host/proc:ro` | Métriques du serveur (et pas seulement celles du conteneur) | +| `/root/backups:/backups` | Sauvegardes | + +## Mettre à jour Big Brother + +Depuis le tableau de bord : **Projets → big-brother → Mettre à jour**. La recréation du conteneur se fait dans un conteneur à part (`docker:cli`) pour ne pas être interrompue par l'arrêt du tableau de bord. La page se reconnecte d'elle-même. + +À la main : + +```bash +cd /srv/fullstack/big-brother +git -C project pull --ff-only +docker compose up -d --build +``` + +## Gestion des comptes + +```bash +docker exec -it big-brother node ace user:create +docker exec -it big-brother node ace user:password +docker exec -it big-brother node ace user:reset-2fa # téléphone perdu +``` + +## Variables d'environnement + +| Variable | Rôle | +|---|---| +| `APP_KEY` | Clé secrète (cookies, chiffrement des secrets 2FA). À ne jamais perdre ni partager. | +| `APP_URL` | URL publique du tableau de bord | +| `TZ` | Fuseau horaire (heure de la sauvegarde automatique) | +| `DB_PATH` | Fichier SQLite (`/data/db.sqlite3`) | +| `SRV_ROOT` | Racine des projets (`/srv`) | +| `HOST_PROC` | `/proc` du serveur monté dans le conteneur (`/host/proc`) | +| `DISK_PATH` | Chemin dont le disque est surveillé (`/srv`) | +| `BACKUP_DIR` | Dossier des sauvegardes dans le conteneur (`/backups`) | +| `DOCKER_SOCKET` | Socket Docker | +| `CADDY_CONTAINER` | Nom du conteneur Caddy | +| `CADDYFILE_PATH` | Caddyfile vu par le tableau de bord (`/srv/caddy/Caddyfile`) | +| `CADDY_CONFIG_PATH` | Caddyfile vu depuis le conteneur Caddy (`/etc/caddy/Caddyfile`) | +| `MARIADB_CONTAINER` | Nom du conteneur MariaDB (son mot de passe root est lu dans le conteneur, via `MARIADB_ROOT_PASSWORD`) | +| `BASE_DOMAIN` | Domaine proposé pour les nouveaux projets (`.`) | +| `BACKUP_DAILY_AT` | Heure de la sauvegarde automatique (`03:00`). Vide = pas de sauvegarde automatique. | +| `BACKUP_KEEP` | Nombre de sauvegardes gardées pour chaque type (7) | + +## Développement local + +Prérequis : Node 24. Docker Desktop est facultatif : sans lui, les pages Conteneurs et les actions affichent « Docker injoignable ». + +```bash +# API (http://localhost:3333) +cd api +npm install +cp .env.example .env +``` + +Dans `api/.env`, adapter les valeurs pour le local : + +- `NODE_ENV=development`, `HOST=localhost`, `APP_KEY` : une valeur quelconque ; +- `SRV_ROOT=../dev-srv` : faux `/srv` fourni pour le développement ; +- `CADDYFILE_PATH=../dev-srv/caddy/Caddyfile` ; +- `BACKUP_DIR=../dev-backups` ; +- supprimer les lignes `DB_PATH`, `HOST_PROC` et `DOCKER_SOCKET`. + +Puis : + +```bash +node ace migration:run +node ace user:create +npm run dev +``` + +```bash +# Interface (http://localhost:4200, les appels /api passent par le proxy vers 3333) +cd web +npm install +npm start +``` + +Pour tester l'interface compilée servie par l'API : `npm run build` dans `web/`, puis copier `web/dist/web/browser/` dans `api/public/`. + +## Limites + +- **Pas d'alerte** : il faut consulter le tableau de bord. +- **Vérifications depuis le VPS** : elles détectent un conteneur arrêté ou une erreur de configuration, pas une panne complète du serveur (le tableau de bord serait lui aussi hors ligne). +- **Sites statiques** : le build se fait sur le PC puis s'envoie avec `scp` (la page du projet affiche les commandes). +- **Création de projet** : en cas d'échec à une étape, les étapes déjà faites ne sont pas annulées. Le journal indique où ça s'est arrêté. diff --git a/api/.adonisjs/client/data.d.ts b/api/.adonisjs/client/data.d.ts new file mode 100644 index 0000000..1513a7a --- /dev/null +++ b/api/.adonisjs/client/data.d.ts @@ -0,0 +1,10 @@ +/** + * This file is automatically generated. + * DO NOT EDIT manually + */ + +/// +import type { InferData, InferVariants } from '@adonisjs/core/types/transformers' + +export namespace Data { +} diff --git a/api/.adonisjs/client/manifest.d.ts b/api/.adonisjs/client/manifest.d.ts new file mode 100644 index 0000000..67be1aa --- /dev/null +++ b/api/.adonisjs/client/manifest.d.ts @@ -0,0 +1,11 @@ +/** + * This file is automatically generated. + * DO NOT EDIT manually + */ + +/// +/// +/// +/// +/// +/// diff --git a/api/.adonisjs/client/registry/index.ts b/api/.adonisjs/client/registry/index.ts new file mode 100644 index 0000000..9469fdf --- /dev/null +++ b/api/.adonisjs/client/registry/index.ts @@ -0,0 +1,221 @@ +/* eslint-disable prettier/prettier */ +import type { AdonisEndpoint } from '@tuyau/core/types' +import type { Registry } from './schema.d.ts' +import type { ApiDefinition } from './tree.d.ts' + +const placeholder: any = {} + +const routes = { + 'auth.login': { + methods: ["POST"], + pattern: '/api/auth/login', + tokens: [{"old":"/api/auth/login","type":0,"val":"api","end":""},{"old":"/api/auth/login","type":0,"val":"auth","end":""},{"old":"/api/auth/login","type":0,"val":"login","end":""}], + types: placeholder as Registry['auth.login']['types'], + }, + 'auth.logout': { + methods: ["POST"], + pattern: '/api/auth/logout', + tokens: [{"old":"/api/auth/logout","type":0,"val":"api","end":""},{"old":"/api/auth/logout","type":0,"val":"auth","end":""},{"old":"/api/auth/logout","type":0,"val":"logout","end":""}], + types: placeholder as Registry['auth.logout']['types'], + }, + 'auth.me': { + methods: ["GET","HEAD"], + pattern: '/api/auth/me', + tokens: [{"old":"/api/auth/me","type":0,"val":"api","end":""},{"old":"/api/auth/me","type":0,"val":"auth","end":""},{"old":"/api/auth/me","type":0,"val":"me","end":""}], + types: placeholder as Registry['auth.me']['types'], + }, + 'overview.index': { + methods: ["GET","HEAD"], + pattern: '/api/overview', + tokens: [{"old":"/api/overview","type":0,"val":"api","end":""},{"old":"/api/overview","type":0,"val":"overview","end":""}], + types: placeholder as Registry['overview.index']['types'], + }, + 'metrics.live': { + methods: ["GET","HEAD"], + pattern: '/api/metrics/live', + tokens: [{"old":"/api/metrics/live","type":0,"val":"api","end":""},{"old":"/api/metrics/live","type":0,"val":"metrics","end":""},{"old":"/api/metrics/live","type":0,"val":"live","end":""}], + types: placeholder as Registry['metrics.live']['types'], + }, + 'metrics.history': { + methods: ["GET","HEAD"], + pattern: '/api/metrics/history', + tokens: [{"old":"/api/metrics/history","type":0,"val":"api","end":""},{"old":"/api/metrics/history","type":0,"val":"metrics","end":""},{"old":"/api/metrics/history","type":0,"val":"history","end":""}], + types: placeholder as Registry['metrics.history']['types'], + }, + 'containers.index': { + methods: ["GET","HEAD"], + pattern: '/api/containers', + tokens: [{"old":"/api/containers","type":0,"val":"api","end":""},{"old":"/api/containers","type":0,"val":"containers","end":""}], + types: placeholder as Registry['containers.index']['types'], + }, + 'containers.show': { + methods: ["GET","HEAD"], + pattern: '/api/containers/:id', + tokens: [{"old":"/api/containers/:id","type":0,"val":"api","end":""},{"old":"/api/containers/:id","type":0,"val":"containers","end":""},{"old":"/api/containers/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['containers.show']['types'], + }, + 'containers.logs': { + methods: ["GET","HEAD"], + pattern: '/api/containers/:id/logs', + tokens: [{"old":"/api/containers/:id/logs","type":0,"val":"api","end":""},{"old":"/api/containers/:id/logs","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/logs","type":1,"val":"id","end":""},{"old":"/api/containers/:id/logs","type":0,"val":"logs","end":""}], + types: placeholder as Registry['containers.logs']['types'], + }, + 'containers.action': { + methods: ["POST"], + pattern: '/api/containers/:id/:action', + tokens: [{"old":"/api/containers/:id/:action","type":0,"val":"api","end":""},{"old":"/api/containers/:id/:action","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/:action","type":1,"val":"id","end":""},{"old":"/api/containers/:id/:action","type":1,"val":"action","end":""}], + types: placeholder as Registry['containers.action']['types'], + }, + 'sites.index': { + methods: ["GET","HEAD"], + pattern: '/api/sites', + tokens: [{"old":"/api/sites","type":0,"val":"api","end":""},{"old":"/api/sites","type":0,"val":"sites","end":""}], + types: placeholder as Registry['sites.index']['types'], + }, + 'sites.check': { + methods: ["POST"], + pattern: '/api/sites/check', + tokens: [{"old":"/api/sites/check","type":0,"val":"api","end":""},{"old":"/api/sites/check","type":0,"val":"sites","end":""},{"old":"/api/sites/check","type":0,"val":"check","end":""}], + types: placeholder as Registry['sites.check']['types'], + }, + 'sites.show': { + methods: ["GET","HEAD"], + pattern: '/api/sites/:id', + tokens: [{"old":"/api/sites/:id","type":0,"val":"api","end":""},{"old":"/api/sites/:id","type":0,"val":"sites","end":""},{"old":"/api/sites/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['sites.show']['types'], + }, + 'projects.index': { + methods: ["GET","HEAD"], + pattern: '/api/projects', + tokens: [{"old":"/api/projects","type":0,"val":"api","end":""},{"old":"/api/projects","type":0,"val":"projects","end":""}], + types: placeholder as Registry['projects.index']['types'], + }, + 'projects.store': { + methods: ["POST"], + pattern: '/api/projects', + tokens: [{"old":"/api/projects","type":0,"val":"api","end":""},{"old":"/api/projects","type":0,"val":"projects","end":""}], + types: placeholder as Registry['projects.store']['types'], + }, + 'projects.options': { + methods: ["GET","HEAD"], + pattern: '/api/projects/options', + tokens: [{"old":"/api/projects/options","type":0,"val":"api","end":""},{"old":"/api/projects/options","type":0,"val":"projects","end":""},{"old":"/api/projects/options","type":0,"val":"options","end":""}], + types: placeholder as Registry['projects.options']['types'], + }, + 'projects.show': { + methods: ["GET","HEAD"], + pattern: '/api/projects/:category/:name', + tokens: [{"old":"/api/projects/:category/:name","type":0,"val":"api","end":""},{"old":"/api/projects/:category/:name","type":0,"val":"projects","end":""},{"old":"/api/projects/:category/:name","type":1,"val":"category","end":""},{"old":"/api/projects/:category/:name","type":1,"val":"name","end":""}], + types: placeholder as Registry['projects.show']['types'], + }, + 'projects.fetch': { + methods: ["POST"], + pattern: '/api/projects/:category/:name/fetch', + tokens: [{"old":"/api/projects/:category/:name/fetch","type":0,"val":"api","end":""},{"old":"/api/projects/:category/:name/fetch","type":0,"val":"projects","end":""},{"old":"/api/projects/:category/:name/fetch","type":1,"val":"category","end":""},{"old":"/api/projects/:category/:name/fetch","type":1,"val":"name","end":""},{"old":"/api/projects/:category/:name/fetch","type":0,"val":"fetch","end":""}], + types: placeholder as Registry['projects.fetch']['types'], + }, + 'projects.action': { + methods: ["POST"], + pattern: '/api/projects/:category/:name/actions/:action', + tokens: [{"old":"/api/projects/:category/:name/actions/:action","type":0,"val":"api","end":""},{"old":"/api/projects/:category/:name/actions/:action","type":0,"val":"projects","end":""},{"old":"/api/projects/:category/:name/actions/:action","type":1,"val":"category","end":""},{"old":"/api/projects/:category/:name/actions/:action","type":1,"val":"name","end":""},{"old":"/api/projects/:category/:name/actions/:action","type":0,"val":"actions","end":""},{"old":"/api/projects/:category/:name/actions/:action","type":1,"val":"action","end":""}], + types: placeholder as Registry['projects.action']['types'], + }, + 'caddy.show': { + methods: ["GET","HEAD"], + pattern: '/api/caddy', + tokens: [{"old":"/api/caddy","type":0,"val":"api","end":""},{"old":"/api/caddy","type":0,"val":"caddy","end":""}], + types: placeholder as Registry['caddy.show']['types'], + }, + 'caddy.update': { + methods: ["PUT"], + pattern: '/api/caddy', + tokens: [{"old":"/api/caddy","type":0,"val":"api","end":""},{"old":"/api/caddy","type":0,"val":"caddy","end":""}], + types: placeholder as Registry['caddy.update']['types'], + }, + 'caddy.reload': { + methods: ["POST"], + pattern: '/api/caddy/reload', + tokens: [{"old":"/api/caddy/reload","type":0,"val":"api","end":""},{"old":"/api/caddy/reload","type":0,"val":"caddy","end":""},{"old":"/api/caddy/reload","type":0,"val":"reload","end":""}], + types: placeholder as Registry['caddy.reload']['types'], + }, + 'caddy.versions': { + methods: ["GET","HEAD"], + pattern: '/api/caddy/versions', + tokens: [{"old":"/api/caddy/versions","type":0,"val":"api","end":""},{"old":"/api/caddy/versions","type":0,"val":"caddy","end":""},{"old":"/api/caddy/versions","type":0,"val":"versions","end":""}], + types: placeholder as Registry['caddy.versions']['types'], + }, + 'caddy.version': { + methods: ["GET","HEAD"], + pattern: '/api/caddy/versions/:id', + tokens: [{"old":"/api/caddy/versions/:id","type":0,"val":"api","end":""},{"old":"/api/caddy/versions/:id","type":0,"val":"caddy","end":""},{"old":"/api/caddy/versions/:id","type":0,"val":"versions","end":""},{"old":"/api/caddy/versions/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['caddy.version']['types'], + }, + 'caddy.restore': { + methods: ["POST"], + pattern: '/api/caddy/versions/:id/restore', + tokens: [{"old":"/api/caddy/versions/:id/restore","type":0,"val":"api","end":""},{"old":"/api/caddy/versions/:id/restore","type":0,"val":"caddy","end":""},{"old":"/api/caddy/versions/:id/restore","type":0,"val":"versions","end":""},{"old":"/api/caddy/versions/:id/restore","type":1,"val":"id","end":""},{"old":"/api/caddy/versions/:id/restore","type":0,"val":"restore","end":""}], + types: placeholder as Registry['caddy.restore']['types'], + }, + 'backups.index': { + methods: ["GET","HEAD"], + pattern: '/api/backups', + tokens: [{"old":"/api/backups","type":0,"val":"api","end":""},{"old":"/api/backups","type":0,"val":"backups","end":""}], + types: placeholder as Registry['backups.index']['types'], + }, + 'backups.store': { + methods: ["POST"], + pattern: '/api/backups', + tokens: [{"old":"/api/backups","type":0,"val":"api","end":""},{"old":"/api/backups","type":0,"val":"backups","end":""}], + types: placeholder as Registry['backups.store']['types'], + }, + 'backups.download': { + methods: ["GET","HEAD"], + pattern: '/api/backups/:name', + tokens: [{"old":"/api/backups/:name","type":0,"val":"api","end":""},{"old":"/api/backups/:name","type":0,"val":"backups","end":""},{"old":"/api/backups/:name","type":1,"val":"name","end":""}], + types: placeholder as Registry['backups.download']['types'], + }, + 'backups.destroy': { + methods: ["DELETE"], + pattern: '/api/backups/:name', + tokens: [{"old":"/api/backups/:name","type":0,"val":"api","end":""},{"old":"/api/backups/:name","type":0,"val":"backups","end":""},{"old":"/api/backups/:name","type":1,"val":"name","end":""}], + types: placeholder as Registry['backups.destroy']['types'], + }, + 'jobs.stream': { + methods: ["GET","HEAD"], + pattern: '/api/jobs/:id/stream', + tokens: [{"old":"/api/jobs/:id/stream","type":0,"val":"api","end":""},{"old":"/api/jobs/:id/stream","type":0,"val":"jobs","end":""},{"old":"/api/jobs/:id/stream","type":1,"val":"id","end":""},{"old":"/api/jobs/:id/stream","type":0,"val":"stream","end":""}], + types: placeholder as Registry['jobs.stream']['types'], + }, + 'audit.index': { + methods: ["GET","HEAD"], + pattern: '/api/audit', + tokens: [{"old":"/api/audit","type":0,"val":"api","end":""},{"old":"/api/audit","type":0,"val":"audit","end":""}], + types: placeholder as Registry['audit.index']['types'], + }, + 'audit.show': { + methods: ["GET","HEAD"], + pattern: '/api/audit/:id', + tokens: [{"old":"/api/audit/:id","type":0,"val":"api","end":""},{"old":"/api/audit/:id","type":0,"val":"audit","end":""},{"old":"/api/audit/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['audit.show']['types'], + }, + 'spa.index': { + methods: ["GET","HEAD"], + pattern: '/*', + tokens: [{"old":"/*","type":2,"val":"*","end":""}], + types: placeholder as Registry['spa.index']['types'], + }, +} as const satisfies Record + +export { routes } + +export const registry = { + routes, + $tree: {} as ApiDefinition, +} + +declare module '@tuyau/core/types' { + export interface UserRegistry { + routes: typeof routes + $tree: ApiDefinition + } +} diff --git a/api/.adonisjs/client/registry/schema.d.ts b/api/.adonisjs/client/registry/schema.d.ts new file mode 100644 index 0000000..c036da6 --- /dev/null +++ b/api/.adonisjs/client/registry/schema.d.ts @@ -0,0 +1,406 @@ +/* eslint-disable prettier/prettier */ +/// + +import type { ExtractBody, ExtractErrorResponse, ExtractQuery, ExtractQueryForGet, ExtractResponse } from '@tuyau/core/types' +import type { InferInput, SimpleError } from '@vinejs/vine/types' + +export type ParamValue = string | number | bigint | boolean + +export interface Registry { + 'auth.login': { + methods: ["POST"] + pattern: '/api/auth/login' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'auth.logout': { + methods: ["POST"] + pattern: '/api/auth/logout' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'auth.me': { + methods: ["GET","HEAD"] + pattern: '/api/auth/me' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'overview.index': { + methods: ["GET","HEAD"] + pattern: '/api/overview' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'metrics.live': { + methods: ["GET","HEAD"] + pattern: '/api/metrics/live' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'metrics.history': { + methods: ["GET","HEAD"] + pattern: '/api/metrics/history' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'containers.index': { + methods: ["GET","HEAD"] + pattern: '/api/containers' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'containers.show': { + methods: ["GET","HEAD"] + pattern: '/api/containers/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'containers.logs': { + methods: ["GET","HEAD"] + pattern: '/api/containers/:id/logs' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'containers.action': { + methods: ["POST"] + pattern: '/api/containers/:id/:action' + types: { + body: {} + paramsTuple: [ParamValue, ParamValue] + params: { id: ParamValue; action: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'sites.index': { + methods: ["GET","HEAD"] + pattern: '/api/sites' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'sites.check': { + methods: ["POST"] + pattern: '/api/sites/check' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'sites.show': { + methods: ["GET","HEAD"] + pattern: '/api/sites/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'projects.index': { + methods: ["GET","HEAD"] + pattern: '/api/projects' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'projects.store': { + methods: ["POST"] + pattern: '/api/projects' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'projects.options': { + methods: ["GET","HEAD"] + pattern: '/api/projects/options' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'projects.show': { + methods: ["GET","HEAD"] + pattern: '/api/projects/:category/:name' + types: { + body: {} + paramsTuple: [ParamValue, ParamValue] + params: { category: ParamValue; name: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'projects.fetch': { + methods: ["POST"] + pattern: '/api/projects/:category/:name/fetch' + types: { + body: {} + paramsTuple: [ParamValue, ParamValue] + params: { category: ParamValue; name: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'projects.action': { + methods: ["POST"] + pattern: '/api/projects/:category/:name/actions/:action' + types: { + body: ExtractBody> + paramsTuple: [ParamValue, ParamValue, ParamValue] + params: { category: ParamValue; name: ParamValue; action: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'caddy.show': { + methods: ["GET","HEAD"] + pattern: '/api/caddy' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'caddy.update': { + methods: ["PUT"] + pattern: '/api/caddy' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'caddy.reload': { + methods: ["POST"] + pattern: '/api/caddy/reload' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'caddy.versions': { + methods: ["GET","HEAD"] + pattern: '/api/caddy/versions' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'caddy.version': { + methods: ["GET","HEAD"] + pattern: '/api/caddy/versions/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'caddy.restore': { + methods: ["POST"] + pattern: '/api/caddy/versions/:id/restore' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'backups.index': { + methods: ["GET","HEAD"] + pattern: '/api/backups' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'backups.store': { + methods: ["POST"] + pattern: '/api/backups' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'backups.download': { + methods: ["GET","HEAD"] + pattern: '/api/backups/:name' + types: { + body: {} + paramsTuple: [ParamValue] + params: { name: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'backups.destroy': { + methods: ["DELETE"] + pattern: '/api/backups/:name' + types: { + body: {} + paramsTuple: [ParamValue] + params: { name: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'jobs.stream': { + methods: ["GET","HEAD"] + pattern: '/api/jobs/:id/stream' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'audit.index': { + methods: ["GET","HEAD"] + pattern: '/api/audit' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'audit.show': { + methods: ["GET","HEAD"] + pattern: '/api/audit/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'spa.index': { + methods: ["GET","HEAD"] + pattern: '/*' + types: { + body: {} + paramsTuple: [ParamValue] + params: { '*': ParamValue[] } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } +} diff --git a/api/.adonisjs/client/registry/tree.d.ts b/api/.adonisjs/client/registry/tree.d.ts new file mode 100644 index 0000000..f8dfd4a --- /dev/null +++ b/api/.adonisjs/client/registry/tree.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable prettier/prettier */ +import type { routes } from './index.ts' + +export interface ApiDefinition { + auth: { + login: typeof routes['auth.login'] + logout: typeof routes['auth.logout'] + me: typeof routes['auth.me'] + } + overview: { + index: typeof routes['overview.index'] + } + metrics: { + live: typeof routes['metrics.live'] + history: typeof routes['metrics.history'] + } + containers: { + index: typeof routes['containers.index'] + show: typeof routes['containers.show'] + logs: typeof routes['containers.logs'] + action: typeof routes['containers.action'] + } + sites: { + index: typeof routes['sites.index'] + check: typeof routes['sites.check'] + show: typeof routes['sites.show'] + } + projects: { + index: typeof routes['projects.index'] + store: typeof routes['projects.store'] + options: typeof routes['projects.options'] + show: typeof routes['projects.show'] + fetch: typeof routes['projects.fetch'] + action: typeof routes['projects.action'] + } + caddy: { + show: typeof routes['caddy.show'] + update: typeof routes['caddy.update'] + reload: typeof routes['caddy.reload'] + versions: typeof routes['caddy.versions'] + version: typeof routes['caddy.version'] + restore: typeof routes['caddy.restore'] + } + backups: { + index: typeof routes['backups.index'] + store: typeof routes['backups.store'] + download: typeof routes['backups.download'] + destroy: typeof routes['backups.destroy'] + } + jobs: { + stream: typeof routes['jobs.stream'] + } + audit: { + index: typeof routes['audit.index'] + show: typeof routes['audit.show'] + } + spa: { + index: typeof routes['spa.index'] + } +} diff --git a/api/.adonisjs/server/controllers.ts b/api/.adonisjs/server/controllers.ts new file mode 100644 index 0000000..d54d1a1 --- /dev/null +++ b/api/.adonisjs/server/controllers.ts @@ -0,0 +1,18 @@ +/** + * This file is automatically generated. + * DO NOT EDIT manually + */ + +export const controllers = { + Audit: () => import('#controllers/audit_controller'), + Auth: () => import('#controllers/auth_controller'), + Backups: () => import('#controllers/backups_controller'), + Caddy: () => import('#controllers/caddy_controller'), + Containers: () => import('#controllers/containers_controller'), + Jobs: () => import('#controllers/jobs_controller'), + Metrics: () => import('#controllers/metrics_controller'), + Overview: () => import('#controllers/overview_controller'), + Projects: () => import('#controllers/projects_controller'), + Sites: () => import('#controllers/sites_controller'), + Spa: () => import('#controllers/spa_controller'), +} diff --git a/api/.adonisjs/server/events.ts b/api/.adonisjs/server/events.ts new file mode 100644 index 0000000..a74e59b --- /dev/null +++ b/api/.adonisjs/server/events.ts @@ -0,0 +1,6 @@ +/** + * This file is automatically generated. + * DO NOT EDIT manually + */ + +export const events = {} diff --git a/api/.adonisjs/server/listeners.ts b/api/.adonisjs/server/listeners.ts new file mode 100644 index 0000000..05f35f8 --- /dev/null +++ b/api/.adonisjs/server/listeners.ts @@ -0,0 +1,6 @@ +/** + * This file is automatically generated. + * DO NOT EDIT manually + */ + +export const listeners = {} diff --git a/api/.adonisjs/server/routes.d.ts b/api/.adonisjs/server/routes.d.ts new file mode 100644 index 0000000..cf49997 --- /dev/null +++ b/api/.adonisjs/server/routes.d.ts @@ -0,0 +1,108 @@ +import '@adonisjs/core/types/http' + +type ParamValue = string | number | bigint | boolean + +export type ScannedRoutes = { + ALL: { + 'auth.login': { paramsTuple?: []; params?: {} } + 'auth.logout': { paramsTuple?: []; params?: {} } + 'auth.me': { paramsTuple?: []; params?: {} } + 'overview.index': { paramsTuple?: []; params?: {} } + 'metrics.live': { paramsTuple?: []; params?: {} } + 'metrics.history': { paramsTuple?: []; params?: {} } + 'containers.index': { paramsTuple?: []; params?: {} } + 'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'containers.action': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'action': ParamValue} } + 'sites.index': { paramsTuple?: []; params?: {} } + 'sites.check': { paramsTuple?: []; params?: {} } + 'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'projects.index': { paramsTuple?: []; params?: {} } + 'projects.store': { paramsTuple?: []; params?: {} } + 'projects.options': { paramsTuple?: []; params?: {} } + 'projects.show': { paramsTuple: [ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue} } + 'projects.fetch': { paramsTuple: [ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue} } + 'projects.action': { paramsTuple: [ParamValue,ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue,'action': ParamValue} } + 'caddy.show': { paramsTuple?: []; params?: {} } + 'caddy.update': { paramsTuple?: []; params?: {} } + 'caddy.reload': { paramsTuple?: []; params?: {} } + 'caddy.versions': { paramsTuple?: []; params?: {} } + 'caddy.version': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'caddy.restore': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'backups.index': { paramsTuple?: []; params?: {} } + 'backups.store': { paramsTuple?: []; params?: {} } + 'backups.download': { paramsTuple: [ParamValue]; params: {'name': ParamValue} } + 'backups.destroy': { paramsTuple: [ParamValue]; params: {'name': ParamValue} } + 'jobs.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'audit.index': { paramsTuple?: []; params?: {} } + 'audit.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'spa.index': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} } + } + POST: { + 'auth.login': { paramsTuple?: []; params?: {} } + 'auth.logout': { paramsTuple?: []; params?: {} } + 'containers.action': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'action': ParamValue} } + 'sites.check': { paramsTuple?: []; params?: {} } + 'projects.store': { paramsTuple?: []; params?: {} } + 'projects.fetch': { paramsTuple: [ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue} } + 'projects.action': { paramsTuple: [ParamValue,ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue,'action': ParamValue} } + 'caddy.reload': { paramsTuple?: []; params?: {} } + 'caddy.restore': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'backups.store': { paramsTuple?: []; params?: {} } + } + GET: { + 'auth.me': { paramsTuple?: []; params?: {} } + 'overview.index': { paramsTuple?: []; params?: {} } + 'metrics.live': { paramsTuple?: []; params?: {} } + 'metrics.history': { paramsTuple?: []; params?: {} } + 'containers.index': { paramsTuple?: []; params?: {} } + 'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'sites.index': { paramsTuple?: []; params?: {} } + 'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'projects.index': { paramsTuple?: []; params?: {} } + 'projects.options': { paramsTuple?: []; params?: {} } + 'projects.show': { paramsTuple: [ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue} } + 'caddy.show': { paramsTuple?: []; params?: {} } + 'caddy.versions': { paramsTuple?: []; params?: {} } + 'caddy.version': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'backups.index': { paramsTuple?: []; params?: {} } + 'backups.download': { paramsTuple: [ParamValue]; params: {'name': ParamValue} } + 'jobs.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'audit.index': { paramsTuple?: []; params?: {} } + 'audit.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'spa.index': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} } + } + HEAD: { + 'auth.me': { paramsTuple?: []; params?: {} } + 'overview.index': { paramsTuple?: []; params?: {} } + 'metrics.live': { paramsTuple?: []; params?: {} } + 'metrics.history': { paramsTuple?: []; params?: {} } + 'containers.index': { paramsTuple?: []; params?: {} } + 'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'sites.index': { paramsTuple?: []; params?: {} } + 'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'projects.index': { paramsTuple?: []; params?: {} } + 'projects.options': { paramsTuple?: []; params?: {} } + 'projects.show': { paramsTuple: [ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue} } + 'caddy.show': { paramsTuple?: []; params?: {} } + 'caddy.versions': { paramsTuple?: []; params?: {} } + 'caddy.version': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'backups.index': { paramsTuple?: []; params?: {} } + 'backups.download': { paramsTuple: [ParamValue]; params: {'name': ParamValue} } + 'jobs.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'audit.index': { paramsTuple?: []; params?: {} } + 'audit.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'spa.index': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} } + } + PUT: { + 'caddy.update': { paramsTuple?: []; params?: {} } + } + DELETE: { + 'backups.destroy': { paramsTuple: [ParamValue]; params: {'name': ParamValue} } + } +} +declare module '@adonisjs/core/types/http' { + export interface RoutesList extends ScannedRoutes {} +} \ No newline at end of file diff --git a/api/.editorconfig b/api/.editorconfig new file mode 100644 index 0000000..f830f40 --- /dev/null +++ b/api/.editorconfig @@ -0,0 +1,22 @@ +# http://editorconfig.org + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.json] +insert_final_newline = unset + +[**.min.js] +indent_style = unset +insert_final_newline = unset + +[MakeFile] +indent_style = space + +[*.md] +trim_trailing_whitespace = false diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..c7e9fe3 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,36 @@ +# Node +TZ=Europe/Paris +PORT=3333 +HOST=0.0.0.0 +NODE_ENV=production + +# App +LOG_LEVEL=info +APP_KEY= +APP_URL=https://big-brother.nucleus-ludus.fr + +# Session +SESSION_DRIVER=cookie + +# Database (SQLite) +DB_PATH=/data/db.sqlite3 + +# VPS paths, as seen from inside the container +SRV_ROOT=/srv +HOST_PROC=/host/proc +DISK_PATH=/srv +BACKUP_DIR=/backups + +# Docker +DOCKER_SOCKET=/var/run/docker.sock +CADDY_CONTAINER=caddy +CADDYFILE_PATH=/srv/caddy/Caddyfile +CADDY_CONFIG_PATH=/etc/caddy/Caddyfile +MARIADB_CONTAINER=mariadb + +# Projects +BASE_DOMAIN=nucleus-ludus.fr + +# Backups (empty BACKUP_DAILY_AT = no automatic backup) +BACKUP_DAILY_AT=03:00 +BACKUP_KEEP=7 diff --git a/api/.env.test b/api/.env.test new file mode 100644 index 0000000..28d2da0 --- /dev/null +++ b/api/.env.test @@ -0,0 +1 @@ +SESSION_DRIVER=memory diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..419ff21 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,26 @@ +# Dependencies and AdonisJS build +node_modules +build +tmp/* +!tmp/.gitkeep + +# Secrets +.env +.env.local +.env.production.local +.env.development.local + +# Angular build (copied from ../web/dist) +public/ + +# Build tools specific +npm-debug.log +yarn-error.log + +# Editors specific +.fleet +.idea +.vscode + +# Platform specific +.DS_Store diff --git a/api/.prettierignore b/api/.prettierignore new file mode 100644 index 0000000..8e91c7d --- /dev/null +++ b/api/.prettierignore @@ -0,0 +1,3 @@ +.adonisjs +node_modules +build diff --git a/api/ace.js b/api/ace.js new file mode 100644 index 0000000..0eefd20 --- /dev/null +++ b/api/ace.js @@ -0,0 +1,27 @@ +/* +|-------------------------------------------------------------------------- +| JavaScript entrypoint for running ace commands +|-------------------------------------------------------------------------- +| +| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD +| PROCESS. +| +| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build +| +| Since, we cannot run TypeScript source code using "node" binary, we need +| a JavaScript entrypoint to run ace commands. +| +| This file registers the "ts-node/esm" hook with the Node.js module system +| and then imports the "bin/console.ts" file. +| +*/ + +/** + * Register hook to process TypeScript files using @poppinss/ts-exec + */ +import '@poppinss/ts-exec' + +/** + * Import ace console entrypoint + */ +await import('./bin/console.js') diff --git a/api/adonisrc.ts b/api/adonisrc.ts new file mode 100644 index 0000000..5436056 --- /dev/null +++ b/api/adonisrc.ts @@ -0,0 +1,126 @@ +import { indexEntities } from '@adonisjs/core/generators' +import { defineConfig } from '@adonisjs/core/app' +import { generateRegistry } from '@tuyau/core/hooks' + +export default defineConfig({ + /* + |-------------------------------------------------------------------------- + | Experimental flags + |-------------------------------------------------------------------------- + | + | The following features will be enabled by default in the next major release + | of AdonisJS. You can opt into them today to avoid any breaking changes + | during upgrade. + | + */ + experimental: {}, + + /* + |-------------------------------------------------------------------------- + | Commands + |-------------------------------------------------------------------------- + | + | List of ace commands to register from packages. The application commands + | will be scanned automatically from the "./commands" directory. + | + */ + commands: [ + () => import('@adonisjs/core/commands'), + () => import('@adonisjs/lucid/commands'), + () => import('@adonisjs/session/commands'), + ], + + /* + |-------------------------------------------------------------------------- + | Service providers + |-------------------------------------------------------------------------- + | + | List of service providers to import and register when booting the + | application + | + */ + providers: [ + () => import('@adonisjs/core/providers/app_provider'), + () => import('@adonisjs/core/providers/hash_provider'), + { + file: () => import('@adonisjs/core/providers/repl_provider'), + environment: ['repl', 'test'], + }, + () => import('@adonisjs/core/providers/vinejs_provider'), + () => import('@adonisjs/session/session_provider'), + () => import('@adonisjs/shield/shield_provider'), + () => import('@adonisjs/lucid/database_provider'), + () => import('@adonisjs/auth/auth_provider'), + () => import('#providers/api_provider'), + () => import('@adonisjs/limiter/limiter_provider'), + () => import('@adonisjs/static/static_provider'), + ], + + /* + |-------------------------------------------------------------------------- + | Preloads + |-------------------------------------------------------------------------- + | + | List of modules to import before starting the application. + | + */ + preloads: [ + () => import('#start/routes'), + () => import('#start/kernel'), + () => import('#start/validator'), + { + file: () => import('#start/monitors'), + environment: ['web'], + }, + ], + + /* + |-------------------------------------------------------------------------- + | Tests + |-------------------------------------------------------------------------- + | + | List of test suites to organize tests by their type. Feel free to remove + | and add additional suites. + | + */ + tests: { + suites: [ + { + files: ['tests/unit/**/*.spec.{ts,js}'], + name: 'unit', + timeout: 2000, + }, + { + files: ['tests/functional/**/*.spec.{ts,js}'], + name: 'functional', + timeout: 30000, + }, + ], + forceExit: false, + }, + + /* + |-------------------------------------------------------------------------- + | Metafiles + |-------------------------------------------------------------------------- + | + | A collection of files you want to copy to the build folder when creating + | the production build. + | + */ + metaFiles: [ + { + pattern: 'public/**', + reloadServer: false, + }, + ], + + hooks: { + init: [ + indexEntities({ + transformers: { enabled: true }, + }), + generateRegistry(), + ], + }, +}) diff --git a/api/app/controllers/audit_controller.ts b/api/app/controllers/audit_controller.ts new file mode 100644 index 0000000..5317a6f --- /dev/null +++ b/api/app/controllers/audit_controller.ts @@ -0,0 +1,50 @@ +import vine from '@vinejs/vine' +import db from '@adonisjs/lucid/services/db' +import type { HttpContext } from '@adonisjs/core/http' +import AuditLog from '#models/audit_log' +import UserError from '#exceptions/user_error' + +const indexValidator = vine.create({ + page: vine.number().withoutDecimals().min(1).optional(), + status: vine.enum(['running', 'success', 'error'] as const).optional(), +}) + +export default class AuditController { + async index({ request }: HttpContext) { + const { page = 1, status } = await indexValidator.validate(request.qs()) + + const query = AuditLog.query() + .select('id', 'username', 'ip', 'action', 'target', 'status', 'created_at', 'finished_at') + .select(db.raw('length(output) > 0 as hasOutput')) + .orderBy('id', 'desc') + if (status) query.where('status', status) + + const result = await query.paginate(page, 50) + return { + meta: { total: result.total, page: result.currentPage, lastPage: result.lastPage }, + data: result.all().map((entry) => ({ + id: entry.id, + username: entry.username, + ip: entry.ip, + action: entry.action, + target: entry.target, + status: entry.status, + createdAt: entry.createdAt.toISO(), + finishedAt: entry.finishedAt?.toISO() ?? null, + hasOutput: Boolean(entry.$extras.hasOutput), + })), + } + } + + async show({ params }: HttpContext) { + const entry = await AuditLog.find(params.id) + if (!entry) throw new UserError('Entrée introuvable.', 404) + return { + id: entry.id, + action: entry.action, + target: entry.target, + status: entry.status, + output: entry.output, + } + } +} diff --git a/api/app/controllers/auth_controller.ts b/api/app/controllers/auth_controller.ts new file mode 100644 index 0000000..c1873ab --- /dev/null +++ b/api/app/controllers/auth_controller.ts @@ -0,0 +1,59 @@ +import User from '#models/user' +import limiter from '@adonisjs/limiter/services/main' +import type { HttpContext } from '@adonisjs/core/http' +import totpService from '#services/totp_service' +import { audit } from '#services/audit' +import { loginValidator } from '#validators/auth' + +class InvalidLogin extends Error {} + +/** + * 5 failed attempts per IP, then blocked for 15 minutes. + */ +const loginLimiter = () => + limiter.use({ requests: 5, duration: '15 mins', blockDuration: '15 mins' }) + +export default class AuthController { + async login(ctx: HttpContext) { + const { request, response, auth } = ctx + const { username, password, code } = await request.validateUsing(loginValidator) + + const penalize = () => + loginLimiter().penalize(`login_${request.ip()}`, async (): Promise => { + const user = await User.verifyCredentials(username, password).catch(() => null) + if (!user || !(await totpService.verify(user, code))) throw new InvalidLogin() + return user + }) + + let result: Awaited> + try { + result = await penalize() + } catch (error) { + if (!(error instanceof InvalidLogin)) throw error + await audit(ctx, { action: 'Connexion refusée', target: username, status: 'error' }) + return response.badRequest({ message: 'Identifiant, mot de passe ou code incorrect.' }) + } + + const [throttled, user] = result + if (throttled) { + const minutes = Math.max(1, Math.ceil(throttled.response.availableIn / 60)) + return response.tooManyRequests({ + message: `Trop de tentatives. Réessaie dans ${minutes} min.`, + }) + } + + await auth.use('web').login(user!) + await audit(ctx, { action: 'Connexion', username: user!.username, status: 'success' }) + return { username: user!.username } + } + + async logout(ctx: HttpContext) { + await audit(ctx, { action: 'Déconnexion', status: 'success' }) + await ctx.auth.use('web').logout() + return { ok: true } + } + + async me({ auth }: HttpContext) { + return { username: auth.getUserOrFail().username } + } +} diff --git a/api/app/controllers/backups_controller.ts b/api/app/controllers/backups_controller.ts new file mode 100644 index 0000000..4e7d908 --- /dev/null +++ b/api/app/controllers/backups_controller.ts @@ -0,0 +1,39 @@ +import type { HttpContext } from '@adonisjs/core/http' +import vpsConfig from '#config/vps' +import backupService from '#services/backup_service' +import { actor, audit } from '#services/audit' + +export default class BackupsController { + async index() { + return { + dir: vpsConfig.backups.dir, + schedule: backupService.schedule, + files: await backupService.list(), + } + } + + async store(ctx: HttpContext) { + const job = await backupService.run(actor(ctx)) + return { jobId: job.id } + } + + async download(ctx: HttpContext) { + const path = backupService.path(ctx.params.name) + await audit(ctx, { + action: 'Téléchargement de sauvegarde', + target: ctx.params.name, + status: 'success', + }) + return ctx.response.attachment(path, ctx.params.name) + } + + async destroy(ctx: HttpContext) { + await backupService.delete(ctx.params.name) + await audit(ctx, { + action: 'Suppression de sauvegarde', + target: ctx.params.name, + status: 'success', + }) + return { ok: true } + } +} diff --git a/api/app/controllers/caddy_controller.ts b/api/app/controllers/caddy_controller.ts new file mode 100644 index 0000000..3dd888b --- /dev/null +++ b/api/app/controllers/caddy_controller.ts @@ -0,0 +1,90 @@ +import type { HttpContext } from '@adonisjs/core/http' +import vpsConfig from '#config/vps' +import CaddyfileVersion from '#models/caddyfile_version' +import UserError from '#exceptions/user_error' +import caddyService, { parseCaddyfile, type ReloadResult } from '#services/caddy_service' +import siteMonitor from '#services/site_monitor' +import { actor, audit } from '#services/audit' +import { updateCaddyfileValidator } from '#validators/caddy' + +export default class CaddyController { + async show() { + const content = await caddyService.read() + return { + path: vpsConfig.caddy.caddyfilePath, + container: vpsConfig.caddy.container, + content, + sites: parseCaddyfile(content), + } + } + + async update(ctx: HttpContext) { + const { content, reason } = await ctx.request.validateUsing(updateCaddyfileValidator) + const result = await caddyService.saveAndReload(content, { + username: actor(ctx).username, + reason: reason || 'Modification manuelle', + }) + return this.#respond(ctx, 'Modification du Caddyfile', result) + } + + async reload(ctx: HttpContext) { + const result = await caddyService.reload() + return this.#respond(ctx, 'Rechargement de Caddy', result) + } + + async versions() { + const versions = await CaddyfileVersion.query() + .select('id', 'username', 'reason', 'created_at') + .orderBy('id', 'desc') + return versions.map((version) => ({ + id: version.id, + username: version.username, + reason: version.reason, + createdAt: version.createdAt.toISO(), + })) + } + + async version({ params }: HttpContext) { + const version = await CaddyfileVersion.find(params.id) + if (!version) throw new UserError('Version introuvable.', 404) + return { + id: version.id, + username: version.username, + reason: version.reason, + createdAt: version.createdAt.toISO(), + content: version.content, + } + } + + async restore(ctx: HttpContext) { + const version = await CaddyfileVersion.find(ctx.params.id) + if (!version) throw new UserError('Version introuvable.', 404) + const result = await caddyService.saveAndReload(version.content, { + username: actor(ctx).username, + reason: `Restauration de la version #${version.id}`, + }) + return this.#respond(ctx, `Restauration du Caddyfile (#${version.id})`, result) + } + + async #respond(ctx: HttpContext, action: string, result: ReloadResult) { + await audit(ctx, { + action, + target: vpsConfig.caddy.caddyfilePath, + status: result.ok ? 'success' : 'error', + output: result.output, + }) + + if (!result.ok) { + return ctx.response.unprocessableEntity({ + message: 'Caddy a refusé la configuration : le Caddyfile est inchangé.', + output: result.output, + }) + } + + /** + * New or removed domains show up right away on the sites page. + */ + siteMonitor.sync().catch(() => {}) + return { ok: true, output: result.output } + } +} diff --git a/api/app/controllers/containers_controller.ts b/api/app/controllers/containers_controller.ts new file mode 100644 index 0000000..4f30754 --- /dev/null +++ b/api/app/controllers/containers_controller.ts @@ -0,0 +1,73 @@ +import vine from '@vinejs/vine' +import type { HttpContext } from '@adonisjs/core/http' +import dockerService from '#services/docker_service' +import { audit } from '#services/audit' +import { openSse } from '#services/sse' + +const ACTION_LABELS = { + start: 'Démarrage du conteneur', + stop: 'Arrêt du conteneur', + restart: 'Redémarrage du conteneur', +} as const + +const actionValidator = vine.create({ + action: vine.enum(['start', 'stop', 'restart'] as const), +}) + +const logsValidator = vine.create({ + tail: vine.number().withoutDecimals().range([0, 5000]).optional(), +}) + +export default class ContainersController { + async index() { + return dockerService.list() + } + + async show({ params }: HttpContext) { + return dockerService.inspect(params.id) + } + + async action(ctx: HttpContext) { + const { action } = await actionValidator.validate(ctx.params) + const info = await dockerService.inspect(ctx.params.id) + + try { + await dockerService.action(info.id, action) + } catch (error) { + await audit(ctx, { + action: ACTION_LABELS[action], + target: info.name, + status: 'error', + output: error instanceof Error ? error.message : String(error), + }) + throw error + } + + await audit(ctx, { action: ACTION_LABELS[action], target: info.name, status: 'success' }) + return dockerService.inspect(info.id) + } + + /** + * Follows the logs of the container (Server-Sent Events). + */ + async logs(ctx: HttpContext) { + const { tail } = await logsValidator.validate(ctx.request.qs()) + const channel = openSse(ctx) + + try { + const stop = await dockerService.followLogs( + ctx.params.id, + { tail: tail ?? 300 }, + (line) => channel.send('line', line), + () => { + channel.send('end', {}) + channel.close() + } + ) + channel.onClose(stop) + } catch (error) { + channel.send('failure', { message: error instanceof Error ? error.message : String(error) }) + channel.close() + } + } +} diff --git a/api/app/controllers/jobs_controller.ts b/api/app/controllers/jobs_controller.ts new file mode 100644 index 0000000..04a1fae --- /dev/null +++ b/api/app/controllers/jobs_controller.ts @@ -0,0 +1,51 @@ +import type { HttpContext } from '@adonisjs/core/http' +import AuditLog from '#models/audit_log' +import UserError from '#exceptions/user_error' +import jobRunner, { type JobLine } from '#services/job_runner' +import { openSse } from '#services/sse' + +export default class JobsController { + /** + * Replays the output of a job, then follows it until the end. Jobs + * finished a while ago are read from the action log. + */ + async stream(ctx: HttpContext) { + const id = Number(ctx.params.id) + const job = jobRunner.get(id) + const entry = job ? null : await AuditLog.find(id) + if (!job && !entry) throw new UserError('Action introuvable.', 404) + + const channel = openSse(ctx) + + if (!job) { + const lines = (entry!.output ?? '') + .split('\n') + .map((text) => ({ ts: 0, stream: 'out', text })) + channel.send('meta', { id, action: entry!.action, target: entry!.target }) + channel.send('lines', lines) + channel.send('end', { status: entry!.status }) + channel.close() + return + } + + channel.send('meta', { id, action: job.meta.action, target: job.meta.target }) + channel.send('lines', job.lines) + if (job.status !== 'running') { + channel.send('end', { status: job.status }) + channel.close() + return + } + + const onLine = (line: JobLine) => channel.send('lines', [line]) + const onEnd = (status: string) => { + channel.send('end', { status }) + channel.close() + } + job.on('line', onLine) + job.once('end', onEnd) + channel.onClose(() => { + job.off('line', onLine) + job.off('end', onEnd) + }) + } +} diff --git a/api/app/controllers/metrics_controller.ts b/api/app/controllers/metrics_controller.ts new file mode 100644 index 0000000..6c43d5e --- /dev/null +++ b/api/app/controllers/metrics_controller.ts @@ -0,0 +1,27 @@ +import vine from '@vinejs/vine' +import type { HttpContext } from '@adonisjs/core/http' +import metricsService, { type HostSample } from '#services/metrics_service' +import { openSse } from '#services/sse' + +const historyValidator = vine.create({ + range: vine.enum(['1h', '24h', '7d', '30d'] as const), +}) + +export default class MetricsController { + /** + * Live samples (every 5 s) for the charts of the overview. + */ + async live(ctx: HttpContext) { + const channel = openSse(ctx) + channel.send('init', metricsService.live) + + const onSample = (sample: HostSample) => channel.send('sample', sample) + metricsService.on('sample', onSample) + channel.onClose(() => metricsService.off('sample', onSample)) + } + + async history({ request }: HttpContext) { + const { range } = await historyValidator.validate(request.qs()) + return metricsService.history(range) + } +} diff --git a/api/app/controllers/overview_controller.ts b/api/app/controllers/overview_controller.ts new file mode 100644 index 0000000..3e56f4a --- /dev/null +++ b/api/app/controllers/overview_controller.ts @@ -0,0 +1,71 @@ +import os from 'node:os' +import Site from '#models/site' +import AuditLog from '#models/audit_log' +import backupService from '#services/backup_service' +import dockerService from '#services/docker_service' +import metricsService from '#services/metrics_service' + +const CERT_WARNING_DAYS = 14 + +export default class OverviewController { + async index() { + const now = Math.floor(Date.now() / 1000) + + const [containers, sites, backups, recent, uptime] = await Promise.all([ + dockerService.list().catch((error: Error) => error), + Site.query().where('active', true).orderBy('domain'), + backupService.list(), + AuditLog.query().orderBy('id', 'desc').limit(8), + metricsService.uptime(), + ]) + + const docker = + containers instanceof Error + ? { ok: false as const, error: containers.message } + : { + ok: true as const, + total: containers.length, + running: containers.filter((c) => c.state === 'running').length, + unhealthy: containers.filter((c) => c.health === 'unhealthy').map((c) => c.name), + stopped: containers.filter((c) => c.state !== 'running').map((c) => c.name), + } + + return { + host: { + latest: metricsService.latest, + uptime, + cpus: os.cpus().length, + }, + docker, + sites: { + total: sites.length, + up: sites.filter((site) => site.lastOk === true).length, + down: sites + .filter((site) => site.lastOk === false) + .map((site) => ({ id: site.id, domain: site.domain, error: site.lastError })), + certSoon: sites + .filter( + (site) => site.certExpiresAt && site.certExpiresAt - now < CERT_WARNING_DAYS * 86_400 + ) + .map((site) => ({ + id: site.id, + domain: site.domain, + days: Math.floor((site.certExpiresAt! - now) / 86_400), + })), + }, + backups: { + last: backups[0] ?? null, + count: backups.length, + schedule: backupService.schedule, + }, + recent: recent.map((entry) => ({ + id: entry.id, + action: entry.action, + target: entry.target, + status: entry.status, + username: entry.username, + createdAt: entry.createdAt.toISO(), + })), + } + } +} diff --git a/api/app/controllers/projects_controller.ts b/api/app/controllers/projects_controller.ts new file mode 100644 index 0000000..1ed1ebb --- /dev/null +++ b/api/app/controllers/projects_controller.ts @@ -0,0 +1,51 @@ +import type { HttpContext } from '@adonisjs/core/http' +import vpsConfig from '#config/vps' +import UserError from '#exceptions/user_error' +import projectService, { PROJECT_ACTIONS, type ProjectAction } from '#services/project_service' +import { DEFAULT_NODE_COMMAND } from '#services/templates' +import { actor } from '#services/audit' +import { createProjectValidator, projectActionValidator } from '#validators/project' + +export default class ProjectsController { + async index() { + return projectService.list() + } + + /** + * Values used by the "new project" form. + */ + async options() { + return { + baseDomain: vpsConfig.baseDomain, + categories: vpsConfig.projectCategories, + srvRoot: vpsConfig.srvRoot, + defaultNodeCommand: DEFAULT_NODE_COMMAND, + } + } + + async show({ params }: HttpContext) { + const project = await projectService.find(params.category, params.name) + return projectService.details(project) + } + + async fetch({ params }: HttpContext) { + const project = await projectService.find(params.category, params.name) + return projectService.fetch(project) + } + + async action(ctx: HttpContext) { + const action = ctx.params.action as ProjectAction + if (!PROJECT_ACTIONS.includes(action)) throw new UserError('Action inconnue.', 404) + + const { commit } = await ctx.request.validateUsing(projectActionValidator) + const project = await projectService.find(ctx.params.category, ctx.params.name) + const job = await projectService.runAction(project, action, { commit }, actor(ctx)) + return { jobId: job.id } + } + + async store(ctx: HttpContext) { + const input = await ctx.request.validateUsing(createProjectValidator) + const job = await projectService.create(input, actor(ctx)) + return { jobId: job.id } + } +} diff --git a/api/app/controllers/sites_controller.ts b/api/app/controllers/sites_controller.ts new file mode 100644 index 0000000..565e4fe --- /dev/null +++ b/api/app/controllers/sites_controller.ts @@ -0,0 +1,77 @@ +import vine from '@vinejs/vine' +import type { HttpContext } from '@adonisjs/core/http' +import Site from '#models/site' +import siteMonitor from '#services/site_monitor' +import UserError from '#exceptions/user_error' + +const showValidator = vine.create({ + range: vine.enum(['24h', '7d', '30d'] as const).optional(), +}) + +const RANGES = { + '24h': { seconds: 86_400, bucket: 300 }, + '7d': { seconds: 7 * 86_400, bucket: 1800 }, + '30d': { seconds: 30 * 86_400, bucket: 7200 }, +} as const + +export default class SitesController { + async index() { + const [sites, day, week] = await Promise.all([ + Site.query().orderBy('active', 'desc').orderBy('domain'), + siteMonitor.stats(86_400), + siteMonitor.stats(7 * 86_400), + ]) + + return sites.map((site) => ({ + ...serializeSite(site), + uptime24h: day.get(site.id)?.uptime ?? null, + uptime7d: week.get(site.id)?.uptime ?? null, + avgResponseMs24h: day.get(site.id)?.avgResponseMs ?? null, + })) + } + + async show({ params, request }: HttpContext) { + const { range = '24h' } = await showValidator.validate(request.qs()) + const site = await Site.find(params.id) + if (!site) throw new UserError('Site introuvable.', 404) + + const { seconds, bucket } = RANGES[range] + const [history, stats] = await Promise.all([ + siteMonitor.history(site.id, seconds, bucket), + siteMonitor.stats(seconds), + ]) + + return { + site: serializeSite(site), + range, + bucket, + uptime: stats.get(site.id)?.uptime ?? null, + avgResponseMs: stats.get(site.id)?.avgResponseMs ?? null, + ...history, + } + } + + /** + * Runs the checks right away instead of waiting for the next minute. + */ + async check() { + await siteMonitor.checkAll() + return this.index() + } +} + +function serializeSite(site: Site) { + return { + id: site.id, + domain: site.domain, + active: site.active, + kind: site.kind, + upstream: site.upstream, + lastOk: site.lastOk, + lastStatus: site.lastStatus, + lastResponseMs: site.lastResponseMs, + lastError: site.lastError, + lastCheckedAt: site.lastCheckedAt, + certExpiresAt: site.certExpiresAt, + } +} diff --git a/api/app/controllers/spa_controller.ts b/api/app/controllers/spa_controller.ts new file mode 100644 index 0000000..01ab88a --- /dev/null +++ b/api/app/controllers/spa_controller.ts @@ -0,0 +1,25 @@ +import { existsSync } from 'node:fs' +import app from '@adonisjs/core/services/app' +import type { HttpContext } from '@adonisjs/core/http' + +/** + * Serves the Angular application for every URL that is neither a file + * of "public/" nor an API route (/containers, /projects/…). + */ +export default class SpaController { + async index({ request, response }: HttpContext) { + if (request.url().startsWith('/api/')) { + return response.notFound({ message: 'Route inconnue.' }) + } + + const index = app.publicPath('index.html') + if (!existsSync(index)) { + return response.notFound( + 'Interface non compilée : lance "npm run build" dans web/ (ou "npm start" pour le développement).' + ) + } + + response.header('Cache-Control', 'no-cache') + return response.download(index) + } +} diff --git a/api/app/exceptions/handler.ts b/api/app/exceptions/handler.ts new file mode 100644 index 0000000..daafb40 --- /dev/null +++ b/api/app/exceptions/handler.ts @@ -0,0 +1,55 @@ +import app from '@adonisjs/core/services/app' +import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http' +import { isDockerUnreachable } from '#services/docker_service' + +export default class HttpExceptionHandler extends ExceptionHandler { + /** + * In debug mode, the exception handler will display verbose errors + * with pretty printed stack traces. + */ + protected debug = !app.inProduction + + /** + * The method is used for handling errors and returning + * response to the client + */ + async handle(error: unknown, ctx: HttpContext) { + /** + * Shield redirects back on a bad CSRF token, which means nothing for + * the API: answer with a JSON error instead. + */ + if ((error as { code?: string })?.code === 'E_BAD_CSRF_TOKEN') { + return ctx.response.forbidden({ + message: 'Session expirée ou jeton de sécurité invalide : recharge la page.', + }) + } + + if (isDockerUnreachable(error)) { + return ctx.response.serviceUnavailable({ + message: 'Docker est injoignable : le socket Docker est-il monté dans le conteneur ?', + }) + } + + /** + * Errors of the Docker API (unknown container, conflict…). + */ + const dockerError = error as { statusCode?: number; json?: { message?: string } } + if (typeof dockerError?.statusCode === 'number' && dockerError.json?.message) { + return ctx.response + .status(dockerError.statusCode === 404 ? 404 : 400) + .send({ message: `Docker : ${dockerError.json.message}` }) + } + + return super.handle(error, ctx) + } + + /** + * The method is used to report error to the logging service or + * the a third party error monitoring service. + * + * @note You should not attempt to send a response from this method. + */ + async report(error: unknown, ctx: HttpContext) { + return super.report(error, ctx) + } +} diff --git a/api/app/exceptions/user_error.ts b/api/app/exceptions/user_error.ts new file mode 100644 index 0000000..d3cd276 --- /dev/null +++ b/api/app/exceptions/user_error.ts @@ -0,0 +1,18 @@ +import { Exception } from '@adonisjs/core/exceptions' +import type { HttpContext } from '@adonisjs/core/http' + +/** + * An error whose message is meant for the user (shown as is in the + * dashboard). Defaults to a 400 status. + */ +export default class UserError extends Exception { + static status = 400 + + constructor(message: string, status?: number) { + super(message, { status: status ?? UserError.status }) + } + + async handle(error: this, ctx: HttpContext) { + ctx.response.status(error.status).send({ message: error.message }) + } +} diff --git a/api/app/middleware/auth_middleware.ts b/api/app/middleware/auth_middleware.ts new file mode 100644 index 0000000..8c86950 --- /dev/null +++ b/api/app/middleware/auth_middleware.ts @@ -0,0 +1,20 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import type { Authenticators } from '@adonisjs/auth/types' + +/** + * Auth middleware is used authenticate HTTP requests and deny + * access to unauthenticated users. + */ +export default class AuthMiddleware { + async handle( + ctx: HttpContext, + next: NextFn, + options: { + guards?: (keyof Authenticators)[] + } = {} + ) { + await ctx.auth.authenticateUsing(options.guards) + return next() + } +} diff --git a/api/app/middleware/container_bindings_middleware.ts b/api/app/middleware/container_bindings_middleware.ts new file mode 100644 index 0000000..8f5322b --- /dev/null +++ b/api/app/middleware/container_bindings_middleware.ts @@ -0,0 +1,19 @@ +import { Logger } from '@adonisjs/core/logger' +import { HttpContext } from '@adonisjs/core/http' +import { type NextFn } from '@adonisjs/core/types/http' + +/** + * The container bindings middleware binds classes to their request + * specific value using the container resolver. + * + * - We bind "HttpContext" class to the "ctx" object + * - And bind "Logger" class to the "ctx.logger" object + */ +export default class ContainerBindingsMiddleware { + handle(ctx: HttpContext, next: NextFn) { + ctx.containerResolver.bindValue(HttpContext, ctx) + ctx.containerResolver.bindValue(Logger, ctx.logger) + + return next() + } +} diff --git a/api/app/middleware/force_json_response_middleware.ts b/api/app/middleware/force_json_response_middleware.ts new file mode 100644 index 0000000..b78c724 --- /dev/null +++ b/api/app/middleware/force_json_response_middleware.ts @@ -0,0 +1,16 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +/** + * The API always answers in JSON, including the errors raised before the + * route middleware (CSRF) and the EventSource requests: an unauthenticated + * stream gets a 401 instead of a redirect. + */ +export default class ForceJsonResponseMiddleware { + handle(ctx: HttpContext, next: NextFn) { + if (ctx.request.url().startsWith('/api/')) { + ctx.request.request.headers.accept = 'application/json' + } + return next() + } +} diff --git a/api/app/middleware/security_headers_middleware.ts b/api/app/middleware/security_headers_middleware.ts new file mode 100644 index 0000000..cc5ffff --- /dev/null +++ b/api/app/middleware/security_headers_middleware.ts @@ -0,0 +1,39 @@ +import app from '@adonisjs/core/services/app' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +/** + * Only same-origin resources. Angular puts the component styles in + *