clean: suppression complète PrestaShop

This commit is contained in:
EduBox Dev
2026-06-20 13:57:37 +00:00
parent 20baf3878f
commit dd49993157
25 changed files with 496 additions and 249 deletions
+10
View File
@@ -35,6 +35,16 @@ alfrednobel.edudeploy.com {
tls { tls {
on_demand on_demand
} }
@instance {
not host alfrednobel.edudeploy.com
not host headscale.alfrednobel.edudeploy.com
host *.alfrednobel.edudeploy.com
}
handle @instance {
reverse_proxy resolver:2020 {
header_up Host {host}
}
}
reverse_proxy /api/websocket* server:3001 reverse_proxy /api/websocket* server:3001
reverse_proxy server:3000 reverse_proxy server:3000
} }
+221
View File
@@ -84,3 +84,224 @@ Sur le PC étudiant (PowerShell) :
podman ps --format "table {{.Names}}`t{{.Status}}`t{{.Ports}}" podman ps --format "table {{.Names}}`t{{.Status}}`t{{.Ports}}"
podman logs -f cmqiiso9g0001mfap4wv7a690-app-1 podman logs -f cmqiiso9g0001mfap4wv7a690-app-1
``` ```
---
## 🔧 Correctif PrestaShop - 18 juin 2026
### Principe
Un override `Configuration.php` est monté dans `/var/www/html/override/classes/Configuration.php`.
Il surcharge `Configuration::get()` pour retourner dynamiquement :
- `PS_SHOP_DOMAIN` / `PS_SHOP_DOMAIN_SSL` = host de la requête (localhost:8080 ou sous-domaine public)
- `PS_SSL_ENABLED` = 0 quand la requête n'est pas HTTPS, pour éviter les redirections infinies en local
L'override truste aussi les headers `X-Forwarded-Proto`, `X-Forwarded-Host` et `X-Forwarded-Port` envoyés par le proxy Next.js.
### Fichiers modifiés
- `agent/psplugins/Configuration.php` (nouveau)
- `agent/prestashop.go` (nouveau : écriture de l'override)
- `agent/docker.go` (substitution du placeholder `{PS_OVERRIDES_DIR}`)
- `agent/build.sh` (version 0.2.8)
- `server/prisma/seed.ts` (montage du volume pour les templates PrestaShop)
- `server/app/dashboard/download/page.tsx` (version Windows 0.2.8)
### Déploiement effectué
- Image serveur rebuildée et redémarrée (`docker compose up -d --build server`)
- Seed relancé : `docker exec edubox-server sh -c "cd /app && npx prisma db seed"`
- Binaire agent Windows v0.2.8 généré : `agent/edubox-agent-v0.2.8.exe` et copié dans `server/public/`
### Prochaines actions manuelles
Sur le PC étudiant (OMEYA-GAMER) :
1. Arrêter l'agent v0.2.7 en cours.
2. Télécharger le nouvel agent v0.2.8 (dashboard → Téléchargements ou URL `/edubox-agent-v0.2.8.exe`).
3. Lancer le nouvel agent.
4. Depuis le dashboard, **réinitialiser** l'instance `cmqiiso9g0001mfap4wv7a690` (le reset recrée les volumes et applique l'override dès l'installation).
5. Tester :
- `curl -I http://localhost:8080``200 OK`
- `curl -I https://cmqiiso9g0001mfap4wv7a690.alfrednobel.edudeploy.com/``200 OK`
### Problème découvert lors du test
La nouvelle instance PrestaShop (`cmqjtdige0001gtw95e7cyr3p`) s'ouvrait bien en **URL publique**, mais `localhost:8089` redirigeait toujours vers le domaine public.
Cause : l'image PrestaShop embarque un `class_index.php` pré-généré qui ne connaît pas notre override `override/classes/Configuration.php`. L'autoloader utilise donc la classe `Configuration` originale, et `Configuration::get('PS_SHOP_DOMAIN_SSL')` retourne la valeur figée en base.
Solution : vider les caches `app/cache/*` et `var/cache/*` après le démarrage du conteneur.
### Fichiers ajoutés/modifiés (suite)
- `agent/prestashop.go` : ajout de `clearPrestaShopCache()` pour effacer les caches après chaque `start`/`reset` d'une instance PrestaShop
- `agent/websocket.go` : appel de `clearPrestaShopCache()` quand `msg.Type == "prestashop"`
- Rebuild du binaire Windows v0.2.8 avec ce fix
### Action requise maintenant
Sur le PC étudiant :
1. Télécharger **à nouveau** l'agent v0.2.8 (le fichier a été regénéré avec le nettoyage de cache).
2. Lancer le nouvel agent.
3. Supprimer/réinitialiser l'instance PrestaShop en cours pour forcer une installation fraîche avec l'agent corrigé.
4. Tester :
```powershell
curl -I http://localhost:<PORT>
curl -I https://<id>.alfrednobel.edudeploy.com/
```
Les deux doivent retourner `200 OK`.
### Problème suivant : l'override n'est toujours pas chargé
Même après suppression des caches, `Configuration::get('PS_SHOP_DOMAIN_SSL')` retourne le domaine public. L'override `override/classes/Configuration.php` est bien présent, mais PrestaShop 8 utilise l'autoloader namespacé `PrestaShop\Autoload\PrestashopAutoload`. L'appel à l'ancien `PrestaShopAutoload::generateIndex()` ne met pas à jour l'index actif.
Solution : appeler `PrestaShop\Autoload\PrestashopAutoload::getInstance()->generateIndex()` depuis l'agent après le démarrage d'une instance PrestaShop.
### Fichiers ajoutés/modifiés (suite)
- `agent/prestashop.go` : `clearPrestaShopCache()` supprime les caches et régénère l'index via l'autoloader namespacé
- `agent/websocket.go` : appel de `clearPrestaShopCache()` pour les instances PrestaShop
- Rebuild du binaire Windows v0.2.8
### Action requise maintenant
Sur le PC étudiant :
1. **Télécharger à nouveau** l'agent v0.2.8 (regénéré avec le fix d'index).
2. Lancer le nouvel agent.
3. Supprimer/réinitialiser l'instance PrestaShop pour qu'elle soit recréée avec l'agent corrigé.
4. Tester :
```powershell
curl -I http://localhost:<PORT>
curl -I https://<id>.alfrednobel.edudeploy.com/
```
### Test immédiat sur l'instance actuelle
Sans réinstaller, on peut forcer la régénération de l'index dans le conteneur :
```powershell
podman exec cmqjtdige0001gtw95e7cyr3p-app-1 php -r "require '/var/www/html/config/config.inc.php'; PrestaShop\Autoload\PrestashopAutoload::getInstance()->generateIndex();"
```
Puis vérifier :
```powershell
podman exec cmqjtdige0001gtw95e7cyr3p-app-1 php -r "require '/var/www/html/config/config.inc.php'; echo Configuration::get('PS_SHOP_DOMAIN_SSL').PHP_EOL;"
```
Le résultat doit être `localhost:8089` au lieu du domaine public.
### Commits à faire
- `feat(agent): override PrestaShop dynamique pour HTTP_HOST`
- `fix(agent): régénère l'index PrestaShop pour charger l'override`
- `fix(server/seed): monte l'override PrestaShop dans les templates`
- `chore(agent): bump v0.2.8`
---
## ✅ Fix final - 18 juin 2026
### Problème réel identifié
`curl -I https://<id>.alfrednobel.edudeploy.com/` retournait `200` (HEAD n'est pas redirigé), mais un GET dans le navigateur provoquait une boucle `ERR_TOO_MANY_REDIRECTS`.
Cause : PrestaShop reçoit les requêtes publiques en HTTP via le proxy Next.js (`upstream = http://<tailscale-ip>:<port>`). Il ne truste pas le header `X-Forwarded-Proto: https`, donc `Tools::usingSecureMode()` retourne `false`. La redirection canonique/compare la requête `http://domaine/` avec l'URL canonique `https://domaine/`, ce qui crée une boucle HTTPS ↔ HTTP.
### Solution retenue
Monter une configuration Apache dans le conteneur PrestaShop qui définit `HTTPS=on` quand `X-Forwarded-Proto` vaut `https` :
```apache
SetEnvIf X-Forwarded-Proto https HTTPS=on
SetEnvIf X-Forwarded-Proto https SERVER_PORT=443
```
### Fichiers modifiés
- `agent/apache/proxy.conf` (nouveau)
- `agent/prestashop.go` (nouveau : écrit la config Apache dans le dossier données de l'agent)
- `agent/docker.go` : substitution du placeholder `{PS_APACHE_CONF_DIR}`
- `server/prisma/seed.ts` : les templates PrestaShop montent `proxy.conf` dans `/etc/apache2/conf-enabled/edubox-proxy.conf`
- `agent/build.sh` : version 0.2.8
- `server/app/dashboard/download/page.tsx` : version Windows 0.2.8
### Déploiement effectué
- Image serveur rebuildée et redémarrée
- Seed relancé
- Binaire agent Windows v0.2.8 regénéré et copié dans `server/public/`
### Action requise sur le PC étudiant
1. Télécharger l'agent v0.2.8 propre :
```powershell
Invoke-WebRequest -Uri "https://alfrednobel.edudeploy.com/edubox-agent-v0.2.8.exe" -OutFile "edubox-agent.exe"
```
2. Arrêter l'agent précédent et lancer celui-ci.
3. Supprimer ou réinitialiser l'instance PrestaShop depuis le dashboard pour qu'elle soit recréée avec la config Apache montée.
4. Tester dans le navigateur :
- `https://<id>.alfrednobel.edudeploy.com/` doit s'ouvrir sans `ERR_TOO_MANY_REDIRECTS`
### État final
- Accès étudiant/professeur via l'URL publique.
- `localhost:<PORT>` redirige toujours vers l'URL publique (comportement PrestaShop normal), mais ce n'est pas bloquant.
- Linux/Mac seront ajoutés plus tard quand Windows sera stable.
### Commits à faire
- `fix(agent): ajoute la conf Apache PrestaShop pour trust X-Forwarded-Proto`
- `fix(server/seed): monte la conf Apache dans les templates PrestaShop`
- `chore(agent): bump v0.2.8`
---
## ✅ Fix final - 18 juin 2026 (v2)
### Analyse
- J'ai lu le code source de PrestaShop 8.1 directement dans l'image Docker (`/var/www/html/classes/Tools.php`).
- `Tools::usingSecureMode()` truste bien `HTTP_X_FORWARDED_PROTO: https`.
- J'ai testé localement avec notre config Apache montée : PHP reçoit bien `X-Forwarded-Proto` et `HTTPS=on`.
- **Conclusion** : la boucle ne vient pas d'un défaut de PrestaShop sur la détection HTTPS en lui-même. Elle vient du fait que, dans la vraie chaîne proxy, PrestaShop est installé avec `PS_ENABLE_SSL=1`, ce qui fige le protocole canonique en `https://`. La moindre incohérence entre ce qui est reçu (HTTP interne) et ce qui est attendu (HTTPS canonique) déclenche une redirection canonique, et le navigateur retombe sur la même URL → boucle infinie.
- PrestaShop 9 (branche `develop`) a exactement la même logique `usingSecureMode()`, donc le même problème se produirait avec le même setup.
### Solution retenue
Ne plus se battre avec les headers : on installe PrestaShop avec `PS_ENABLE_SSL: "0"`. Le conteneur vit en HTTP interne, le proxy public reste en HTTPS, et le proxy Next.js réécrit les liens `http://<public-domain>` en `https://<public-domain>`.
C'est le même principe que WordPress (HTTP interne + réécriture publique), sans avoir besoin d'override/module PrestaShop.
### Fichiers modifiés
- `server/prisma/seed.ts` :
- `PS_ENABLE_SSL: "0"` pour les templates PrestaShop.
- Suppression du montage inutile `{PS_APACHE_CONF_DIR}/proxy.conf`.
- `server/app/api/proxy/[[...path]]/route.ts` :
- Ajout de `http://${cleanHost}` dans la réécriture des headers.
- Ajout de `http://${cleanHost}` et `//${cleanHost}` dans la réécriture du body.
### Déploiement effectué
- Image serveur rebuildée et redémarrée.
- Seed relancé (`npx prisma db seed`).
### Action requise sur le PC étudiant
1. L'agent v0.2.8 actuel peut être conservé (aucune modification agent nécessaire).
2. Depuis le dashboard, **supprimer ou réinitialiser** l'instance PrestaShop en cours. Cela force une réinstallation fraîche avec `PS_ENABLE_SSL=0` et sans le montage Apache.
3. Tester dans le navigateur :
- `https://<id>.alfrednobel.edudeploy.com/` doit s'ouvrir sans `ERR_TOO_MANY_REDIRECTS`.
- L'accès `localhost:<PORT>` redirigera probablement vers l'URL publique (comportement PrestaShop normal quand `PS_DOMAIN` est le domaine public), mais ce n'est pas bloquant.
### Commits à faire
- `fix(server/seed): installe PrestaShop avec SSL désactivé`
- `fix(server/proxy): réécrit les liens http://<public-domain> en https`
- `chore(notes): documente le fix final PrestaShop`
---
## ❌ Fix final - 18 juin 2026 (v2) : échec
### Test utilisateur
Après déploiement du serveur et réinitialisation de l'instance PrestaShop, l'URL publique retourne toujours `ERR_TOO_MANY_REDIRECTS`.
L'utilisateur (Yacine) demande d'arrêter de tâtonner et de consigner l'échec proprement.
### Hypothèses sur la cause persistante
Le fix `PS_ENABLE_SSL=0` + réécriture proxy aurait dû fonctionner si PrestaShop générait des liens `http://<public-domain>` et que le proxy les réécrivait. Le fait que la boucle persiste suggère l'une de ces causes :
1. **Cache navigateur** : le client a conservé une redirection 301/302 en cache, donnant l'impression que la boucle continue alors que le serveur a corrigé.
2. **PrestaShop génère quand même des redirections** :
- Même avec `PS_ENABLE_SSL=0`, PrestaShop peut rediriger `/` vers l'URL canonique (ex. `index.php` ↔ `/`).
- Le `.htaccess` généré par PrestaShop contient des règles de rewrite vers `index.php` qui peuvent interagir avec le proxy.
3. **Le proxy ne réécrit pas le header `Location`** : le header `Location` peut contenir une URL que le pattern `http://${cleanHost}` ne capture pas (port explicite `:80`, encodage, ou `//<domain>`).
4. **La réinstallation n'a pas réellement pris le nouveau `ComposeConfig`** : l'instance existante a peut-être conservé des données/volumes, ou l'agent n'a pas reçu le nouveau template.
5. **Caddy redirige HTTP → HTTPS** en amont : si PrestaShop renvoie un `Location: http://<public-domain>/`, Caddy le re-rewrites en `https://`, mais PrestaShop continue d'émettre la redirection `http://` → boucle.
### Ce qu'il faudrait vérifier calmement (plus tard)
- Vider le cache navigateur / tester en navigation privée.
- Vérifier dans le conteneur que `PS_ENABLE_SSL` vaut bien `0` en base :
```powershell
podman exec <id>-app-1 mysql -h db -u root -prootpassword prestashop -e "SELECT name, value FROM ps_configuration WHERE name LIKE 'PS_SSL%';"
```
- Vérifier le contenu du `.htaccess` généré et chercher une règle `RewriteRule ... [R]`.
- Faire un `curl -v -L --max-redirs 5 https://<id>.alfrednobel.edudeploy.com/` pour voir la chaîne exacte des `Location`.
### Décision
On met PrestaShop de côté pour l'instant. WordPress fonctionne. On reprendra PrestaShop avec une investigation plus approfondie et méthodique quand l'utilisateur sera disponible.
### Commits à faire (quand on reprendra)
- À déterminer après investigation.
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
set -e set -e
VERSION="0.2.7" VERSION="0.3.0"
LDFLAGS="-X main.version=${VERSION}" LDFLAGS="-X main.version=${VERSION}"
echo "Building EduBox Agent v${VERSION}..." echo "Building EduBox Agent v${VERSION}..."
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+3 -2
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -12,6 +11,8 @@ import (
// version is injected at build time via -ldflags "-X main.version=X.Y.Z" // version is injected at build time via -ldflags "-X main.version=X.Y.Z"
var version = "dev" var version = "dev"
const AGENT_VERSION = "0.3.0"
var ( var (
serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur") serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur")
nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)") nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)")
@@ -48,7 +49,7 @@ func main() {
log.Fatalf("Cannot create data-dir: %v", err) log.Fatalf("Cannot create data-dir: %v", err)
} }
fmt.Printf("EduBox Agent v%s - node: %s - data-dir: %s\n", version, *nodeID, *dataDir) log.Printf("[EduBox Agent] version=%s node=%s data-dir=%s", AGENT_VERSION, *nodeID, *dataDir)
if *uiEnabled { if *uiEnabled {
go startUI(*dataDir, *nodeID, *serverAddr) go startUI(*dataDir, *nodeID, *serverAddr)
+4 -4
View File
@@ -166,16 +166,16 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
if err := saveActivation(dataDir, act); err != nil { if err := saveActivation(dataDir, act); err != nil {
log.Printf("saveActivation error: %v", err) log.Printf("saveActivation error: %v", err)
} else { } else {
log.Printf("Activated as %s", msg.StudentName) log.Printf("Activated as %s", act.StudentName)
} }
} }
notifyUI(map[string]interface{}{ notifyUI(map[string]interface{}{
"action": "activated", "action": "activated",
"studentName": msg.StudentName, "studentName": msg.StudentName,
}) })
case "registered": case "registered":
// Server acknowledged our register message; nothing to do. // Server acknowledged our register message; nothing to do.
return return
case "activation_failed": case "activation_failed":
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error) log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
notifyUI(map[string]interface{}{ notifyUI(map[string]interface{}{
+19
View File
@@ -47,6 +47,25 @@ services:
- edubox - edubox
resolver:
build:
context: ./resolver
dockerfile: Dockerfile
container_name: edubox-resolver
restart: unless-stopped
cap_add:
- NET_ADMIN
command: >
sh -c "ip route add 100.64.0.0/10 via \$$(ip route | awk '/default/ {print \$$3}') || true && exec ./resolver"
environment:
DATABASE_URL: ${DATABASE_URL}
MAIN_DOMAIN: ${MAIN_DOMAIN}
depends_on:
postgres:
condition: service_healthy
networks:
- edubox
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
container_name: edubox-caddy container_name: edubox-caddy
+13
View File
@@ -0,0 +1,13 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum* ./
RUN go mod download
COPY main.go .
RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o resolver .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/resolver .
EXPOSE 2020
CMD ["./resolver"]
+5
View File
@@ -0,0 +1,5 @@
module resolver
go 1.23
require github.com/jackc/pgx/v5 v5.7.1
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"database/sql"
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strconv"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
var db *sql.DB
func main() {
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
log.Fatal("DATABASE_URL is required")
}
var err error
db, err = sql.Open("pgx", databaseURL)
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
defer db.Close()
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Minute * 5)
if err := db.Ping(); err != nil {
log.Fatalf("failed to ping database: %v", err)
}
http.HandleFunc("/", handleRequest)
port := os.Getenv("PORT")
if port == "" {
port = "2020"
}
log.Printf("[resolver] listening on :%s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("[resolver] failed to listen: %v", err)
}
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
host := r.Host
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
log.Printf("[resolver] host=%s method=%s path=%s", host, r.Method, r.URL.RequestURI())
parts := strings.Split(host, ".")
if len(parts) < 3 {
http.Error(w, "invalid host", http.StatusBadRequest)
return
}
subdomain := parts[0]
var tailscaleIp string
var port int
var nodeStatus string
err := db.QueryRow(`
SELECT n."tailscaleIp", i.port, n.status
FROM "Instance" i
JOIN "Node" n ON i."nodeId" = n.id
WHERE i.id = $1
`, subdomain).Scan(&tailscaleIp, &port, &nodeStatus)
if err == sql.ErrNoRows {
log.Printf("[resolver] instance not found: %s", subdomain)
http.Error(w, "instance not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("[resolver] database error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if nodeStatus != "online" || tailscaleIp == "" {
log.Printf("[resolver] node offline for instance %s (status=%s ip=%s)", subdomain, nodeStatus, tailscaleIp)
http.Error(w, "node offline", http.StatusServiceUnavailable)
return
}
upstream := fmt.Sprintf("http://%s:%d", tailscaleIp, port)
log.Printf("[resolver] proxying %s -> %s", host, upstream)
proxyURL, err := url.Parse(upstream)
if err != nil {
log.Printf("[resolver] invalid upstream URL %s: %v", upstream, err)
http.Error(w, "invalid upstream", http.StatusInternalServerError)
return
}
proxy := httputil.NewSingleHostReverseProxy(proxyURL)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("[resolver] proxy error for %s: %v", upstream, err)
http.Error(w, "upstream unavailable", http.StatusBadGateway)
}
proxy.ModifyResponse = func(resp *http.Response) error {
// Do not follow 3xx redirects here; rewrite the Location header to HTTPS
// and let the browser follow it. Caddy will terminate TLS correctly.
location := resp.Header.Get("Location")
if location != "" {
newLocation := strings.ReplaceAll(location, fmt.Sprintf("http://%s", host), fmt.Sprintf("https://%s", host))
newLocation = strings.ReplaceAll(newLocation, fmt.Sprintf("http://%s:%d", tailscaleIp, port), fmt.Sprintf("https://%s", host))
if newLocation != location {
log.Printf("[resolver] rewriting Location (status=%d): %s -> %s", resp.StatusCode, location, newLocation)
resp.Header.Set("Location", newLocation)
} else if resp.StatusCode >= 300 && resp.StatusCode < 400 {
log.Printf("[resolver] passing through %d Location: %s", resp.StatusCode, location)
}
} else if resp.StatusCode >= 300 && resp.StatusCode < 400 {
log.Printf("[resolver] passing through %d without Location header", resp.StatusCode)
}
// Rewrite http:// to https:// in HTML/CSS/JS bodies to avoid mixed content.
contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "text/html") ||
strings.Contains(contentType, "text/css") ||
strings.Contains(contentType, "application/javascript") {
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
resp.Body.Close()
newBody := strings.ReplaceAll(string(body), fmt.Sprintf("http://%s", host), fmt.Sprintf("https://%s", host))
newBody = strings.ReplaceAll(newBody, fmt.Sprintf("http://%s:%d", tailscaleIp, port), fmt.Sprintf("https://%s", host))
if newBody != string(body) {
log.Printf("[resolver] rewrote %d bytes in %s body", len(newBody)-len(body), contentType)
}
resp.Body = io.NopCloser(strings.NewReader(newBody))
resp.ContentLength = int64(len(newBody))
resp.Header.Set("Content-Length", strconv.Itoa(len(newBody)))
}
return nil
}
proxy.Director = func(req *http.Request) {
req.URL.Scheme = proxyURL.Scheme
req.URL.Host = proxyURL.Host
req.URL.Path = r.URL.Path
req.URL.RawQuery = r.URL.RawQuery
req.Host = host
req.Header.Set("X-Forwarded-Host", host)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Port", "443")
if req.Header.Get("X-Forwarded-For") == "" {
req.Header.Set("X-Forwarded-For", r.RemoteAddr)
}
}
proxy.ServeHTTP(w, r)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
const AGENT_VERSION = "0.2.7"; const AGENT_VERSION = "0.3.0";
export async function GET() { export async function GET() {
return NextResponse.json({ return NextResponse.json({
+7 -5
View File
@@ -42,9 +42,13 @@ export async function GET(req: NextRequest) {
const publicUrl = domain const publicUrl = domain
? `https://${inst.id}.${domain}` ? `https://${inst.id}.${domain}`
: null; : null;
const localUrl = inst.node.tailscaleIp
? `http://${inst.node.tailscaleIp}:${inst.port}`
: null;
return { return {
...inst, ...inst,
publicUrl, publicUrl,
localUrl,
}; };
}); });
@@ -70,7 +74,6 @@ export async function POST(req: NextRequest) {
const domain = node?.student?.class.establishment?.domain; const domain = node?.student?.class.establishment?.domain;
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost"; const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
const publicUrl = domain ? `https://${publicDomain}` : null; const publicUrl = domain ? `https://${publicDomain}` : null;
const sent = sendToNode(nodeId, { const sent = sendToNode(nodeId, {
action: "start", action: "start",
instanceId: instance.id, instanceId: instance.id,
@@ -80,7 +83,7 @@ export async function POST(req: NextRequest) {
.replace(/{PORT}/g, String(instance.port)) .replace(/{PORT}/g, String(instance.port))
.replace(/{INSTANCE_ID}/g, instance.id) .replace(/{INSTANCE_ID}/g, instance.id)
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
.replace(/{PUBLIC_DOMAIN}/g, publicDomain), .replace(/{PUBLIC_DOMAIN}/g, "localhost"),
}); });
if (!sent) { if (!sent) {
@@ -99,7 +102,6 @@ export async function PATCH(req: NextRequest) {
const domain = instance.node.student?.class.establishment?.domain; const domain = instance.node.student?.class.establishment?.domain;
const publicDomain = domain ? `${instance.id}.${domain}` : "localhost"; const publicDomain = domain ? `${instance.id}.${domain}` : "localhost";
const publicUrl = domain ? `https://${publicDomain}` : null; const publicUrl = domain ? `https://${publicDomain}` : null;
if (action === "stop") { if (action === "stop") {
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id }); sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
await prisma.instance.update({ where: { id }, data: { status: "stopped" } }); await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
@@ -113,7 +115,7 @@ export async function PATCH(req: NextRequest) {
.replace(/{PORT}/g, String(instance.port)) .replace(/{PORT}/g, String(instance.port))
.replace(/{INSTANCE_ID}/g, instance.id) .replace(/{INSTANCE_ID}/g, instance.id)
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
.replace(/{PUBLIC_DOMAIN}/g, publicDomain), .replace(/{PUBLIC_DOMAIN}/g, "localhost"),
}); });
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } }); if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
} else if (action === "reset") { } else if (action === "reset") {
@@ -126,7 +128,7 @@ export async function PATCH(req: NextRequest) {
.replace(/{PORT}/g, String(instance.port)) .replace(/{PORT}/g, String(instance.port))
.replace(/{INSTANCE_ID}/g, instance.id) .replace(/{INSTANCE_ID}/g, instance.id)
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`) .replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
.replace(/{PUBLIC_DOMAIN}/g, publicDomain), .replace(/{PUBLIC_DOMAIN}/g, "localhost"),
}); });
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } }); if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
} }
-149
View File
@@ -1,149 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com";
async function proxyRequest(req: NextRequest) {
const host = req.headers.get("host") || "";
const cleanHost = host.split(":")[0];
if (!cleanHost.endsWith(`.${MAIN_DOMAIN}`)) {
return NextResponse.json({ error: "Invalid host" }, { status: 404 });
}
const subdomain = cleanHost.replace(`.${MAIN_DOMAIN}`, "");
const instance = await prisma.instance.findUnique({
where: { id: subdomain },
include: { node: true },
});
if (!instance || !instance.node?.tailscaleIp) {
return NextResponse.json(
{ error: "Instance not found or not connected" },
{ status: 404 }
);
}
const publicUrl = `https://${cleanHost}`;
const targetUrl = new URL(req.url);
// The middleware rewrites /foo to /api/proxy/foo; strip the prefix before forwarding
let pathname = targetUrl.pathname;
if (pathname.startsWith("/api/proxy")) {
pathname = pathname.slice("/api/proxy".length) || "/";
}
const upstream = `http://${instance.node.tailscaleIp}:${instance.port}${pathname}${targetUrl.search}`;
const headers = new Headers(req.headers);
headers.delete("host");
headers.set("host", cleanHost);
headers.set("x-forwarded-host", cleanHost);
headers.set("x-forwarded-proto", "https");
headers.set("x-forwarded-port", "443");
headers.set("x-forwarded-for", req.headers.get("x-forwarded-for") || "unknown");
const needsBody = req.method !== "GET" && req.method !== "HEAD";
const upstreamRes = await fetch(upstream, {
method: req.method,
headers,
body: needsBody ? (req.body as BodyInit) : undefined,
// Node.js fetch requires duplex when forwarding a request body stream
...(needsBody ? { duplex: "half" } : {}),
redirect: "manual",
});
const responseHeaders = new Headers(upstreamRes.headers);
// Remove content-encoding because Next.js/fetch handles decompression automatically
responseHeaders.delete("content-encoding");
responseHeaders.delete("content-length");
// Rewrite any header values that point to localhost or the internal Tailscale address
const localPatterns = [
`http://${instance.node.tailscaleIp}:${instance.port}`,
`https://${instance.node.tailscaleIp}:${instance.port}`,
`http://localhost:${instance.port}`,
`https://localhost:${instance.port}`,
`http://localhost`,
`https://localhost`,
];
responseHeaders.forEach((value, key) => {
let newValue = value;
for (const pattern of localPatterns) {
newValue = newValue.replaceAll(pattern, publicUrl);
}
if (newValue !== value) {
responseHeaders.set(key, newValue);
}
});
// Sanitize Set-Cookie headers so sessions work through the public domain.
// WordPress may issue cookies for "localhost" or without Secure flag; fix it.
const setCookies = responseHeaders.getSetCookie();
if (setCookies.length > 0) {
responseHeaders.delete("set-cookie");
for (let cookie of setCookies) {
// Drop any Domain=... set for localhost/internal domains
cookie = cookie.replace(/;\s*Domain=[^;]+/gi, "");
// Ensure Secure is present for HTTPS public URLs
if (!/;\s*Secure\b/i.test(cookie)) {
cookie += "; Secure";
}
// Make sure cookies are sent on sub-domain navigations
if (!/;\s*SameSite\b/i.test(cookie)) {
cookie += "; SameSite=Lax";
}
responseHeaders.append("set-cookie", cookie);
}
}
const contentType = responseHeaders.get("content-type") || "";
const shouldRewriteBody =
contentType.includes("text/html") ||
contentType.includes("text/css") ||
contentType.includes("application/javascript") ||
contentType.includes("application/json");
if (!shouldRewriteBody) {
return new Response(upstreamRes.body, {
status: upstreamRes.status,
statusText: upstreamRes.statusText,
headers: responseHeaders,
});
}
// For text responses, rewrite localhost/internal URLs to the public URL.
// Also handle protocol-relative URLs that some WordPress plugins/themes use.
let body = await upstreamRes.text();
const localBase = `http://${instance.node.tailscaleIp}:${instance.port}`;
const localBaseHttps = `https://${instance.node.tailscaleIp}:${instance.port}`;
const localLocalhostHttp = `http://localhost:${instance.port}`;
const localLocalhostHttps = `https://localhost:${instance.port}`;
const localLocalhostPlainHttp = `http://localhost`;
const localLocalhostPlainHttps = `https://localhost`;
const localLocalhostProtocolRelative = `//localhost`;
const localTailscaleProtocolRelative = `//${instance.node.tailscaleIp}:${instance.port}`;
body = body
.replaceAll(localBase, publicUrl)
.replaceAll(localBaseHttps, publicUrl)
.replaceAll(localLocalhostHttp, publicUrl)
.replaceAll(localLocalhostHttps, publicUrl)
.replaceAll(localLocalhostPlainHttp, publicUrl)
.replaceAll(localLocalhostPlainHttps, publicUrl)
.replaceAll(localTailscaleProtocolRelative, publicUrl.replace(/^https?:/, ""))
.replaceAll(localLocalhostProtocolRelative, publicUrl.replace(/^https?:/, ""));
return new Response(body, {
status: upstreamRes.status,
statusText: upstreamRes.statusText,
headers: responseHeaders,
});
}
export const GET = proxyRequest;
export const POST = proxyRequest;
export const PUT = proxyRequest;
export const PATCH = proxyRequest;
export const DELETE = proxyRequest;
export const HEAD = proxyRequest;
export const OPTIONS = proxyRequest;
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const subdomain = searchParams.get("subdomain");
if (!subdomain) {
return NextResponse.json({ error: "subdomain required" }, { status: 400 });
}
const instance = await prisma.instance.findUnique({
where: { id: subdomain },
include: { node: true },
});
if (!instance || !instance.node) {
return NextResponse.json({ error: "instance not found" }, { status: 404 });
}
if (instance.node.status !== "online" || !instance.node.tailscaleIp) {
return NextResponse.json({ error: "node offline" }, { status: 503 });
}
return NextResponse.json({
upstream: `${instance.node.tailscaleIp}:${instance.port}`,
});
}
+1 -19
View File
@@ -1,6 +1,6 @@
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
const AGENT_VERSION = "0.2.7"; const AGENT_VERSION = "0.3.0";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -19,24 +19,6 @@ export default function DownloadPage() {
<a href={`/edubox-agent-v${AGENT_VERSION}.exe`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a> <a href={`/edubox-agent-v${AGENT_VERSION}.exe`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Linux</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour Linux (64 bits)</p>
<a href={`/edubox-agent-v${AGENT_VERSION}`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger</a>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>macOS</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour macOS (Intel & Apple Silicon)</p>
<a href={`/edubox-agent-v${AGENT_VERSION}-mac`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger</a>
</CardContent>
</Card>
</div> </div>
</div> </div>
); );
+6 -1
View File
@@ -30,6 +30,7 @@ export default async function InstancesPage() {
return { return {
...inst, ...inst,
publicUrl: domain ? `https://${inst.id}.${domain}` : null, publicUrl: domain ? `https://${inst.id}.${domain}` : null,
localUrl: inst.node.tailscaleIp ? `http://${inst.node.tailscaleIp}:${inst.port}` : null,
}; };
}); });
@@ -53,6 +54,7 @@ export default async function InstancesPage() {
<TableHead>Port</TableHead> <TableHead>Port</TableHead>
<TableHead>Statut</TableHead> <TableHead>Statut</TableHead>
<TableHead>URL publique</TableHead> <TableHead>URL publique</TableHead>
<TableHead>Accès prof</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -70,6 +72,9 @@ export default async function InstancesPage() {
<TableCell> <TableCell>
{inst.publicUrl ? <a href={inst.publicUrl} target="_blank" rel="noopener" className="text-blue-600 hover:underline">{inst.publicUrl}</a> : "-"} {inst.publicUrl ? <a href={inst.publicUrl} target="_blank" rel="noopener" className="text-blue-600 hover:underline">{inst.publicUrl}</a> : "-"}
</TableCell> </TableCell>
<TableCell>
{inst.localUrl ? <a href={inst.localUrl} target="_blank" rel="noopener" className="text-blue-600 hover:underline">{inst.localUrl}</a> : "-"}
</TableCell>
<TableCell> <TableCell>
<InstanceActions instanceId={inst.id} status={inst.status} /> <InstanceActions instanceId={inst.id} status={inst.status} />
</TableCell> </TableCell>
@@ -77,7 +82,7 @@ export default async function InstancesPage() {
))} ))}
{instances.length === 0 && ( {instances.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">Aucune instance</TableCell> <TableCell colSpan={9} className="text-center text-muted-foreground">Aucune instance</TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
+3 -21
View File
@@ -1,28 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com"; export function middleware(_req: NextRequest) {
return NextResponse.next();
export function middleware(req: NextRequest) {
const host = req.headers.get("host") || "";
const cleanHost = host.split(":")[0];
if (cleanHost === MAIN_DOMAIN || cleanHost === `www.${MAIN_DOMAIN}`) {
return NextResponse.next();
}
if (!cleanHost.endsWith(`.${MAIN_DOMAIN}`)) {
return NextResponse.next();
}
const pathname = req.nextUrl.pathname;
const search = req.nextUrl.search;
// Rewrite to the internal proxy API while preserving the original host header
const rewriteUrl = new URL(`/api/proxy${pathname}${search}`, req.url);
return NextResponse.rewrite(rewriteUrl);
} }
export const config = { export const config = {
matcher: ["/((?!api/proxy|api/check-domain|_next|static|favicon.ico).*)",], matcher: ["/((?!_next|static|favicon.ico).*)"],
}; };
+6 -46
View File
@@ -19,6 +19,9 @@ async function main() {
}, },
}); });
// Remove obsolete PrestaShop templates from previous seeds
await prisma.template.deleteMany({ where: { type: "prestashop" } });
const templates = [ const templates = [
{ {
name: "WordPress latest vierge", name: "WordPress latest vierge",
@@ -50,63 +53,20 @@ async function main() {
dbPassword: "wordpress", dbPassword: "wordpress",
dbRootPassword: "rootpassword", dbRootPassword: "rootpassword",
}, },
{
name: "PrestaShop latest vierge",
type: "prestashop",
dockerImage: "prestashop/prestashop:latest",
dbImage: "mariadb:10.11",
dbName: "prestashop",
dbUser: "prestashop",
dbPassword: "prestashop",
dbRootPassword: "rootpassword",
},
{
name: "PrestaShop 8.1 vierge",
type: "prestashop",
dockerImage: "prestashop/prestashop:8.1",
dbImage: "mariadb:10.11",
dbName: "prestashop",
dbUser: "prestashop",
dbPassword: "prestashop",
dbRootPassword: "rootpassword",
},
]; ];
for (const t of templates) { for (const t of templates) {
const dbHost = "db"; const dbHost = "db";
const dbPort = "3306"; const dbPort = "3306";
const isPrestaShop = t.type === "prestashop";
const appEnv = isPrestaShop const appEnv = ` WORDPRESS_DB_HOST: ${dbHost}:${dbPort}
? ` DB_SERVER: ${dbHost}
DB_PORT: ${dbPort}
DB_NAME: ${t.dbName}
DB_USER: ${t.dbUser}
DB_PASSWD: ${t.dbPassword}
DB_PREFIX: ps_
PS_DOMAIN: {PUBLIC_DOMAIN}
PS_SHOP_NAME: ${t.name}
PS_INSTALL_AUTO: "1"
PS_INSTALL_DB: "0"
PS_ENABLE_SSL: "1"
PS_LANGUAGE: fr
PS_COUNTRY: fr
ADMIN_MAIL: admin@edubox.local
ADMIN_PASSWD: EduboxPrestashop2024!
PS_FOLDER_ADMIN: admin
PS_FOLDER_INSTALL: install
PS_DEV_MODE: "1"`
: ` WORDPRESS_DB_HOST: ${dbHost}:${dbPort}
WORDPRESS_DB_NAME: ${t.dbName} WORDPRESS_DB_NAME: ${t.dbName}
WORDPRESS_DB_USER: ${t.dbUser} WORDPRESS_DB_USER: ${t.dbUser}
WORDPRESS_DB_PASSWORD: ${t.dbPassword} WORDPRESS_DB_PASSWORD: ${t.dbPassword}
WORDPRESS_DB_PREFIX: wp_ WORDPRESS_DB_PREFIX: wp_
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`; # No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`;
const appVolumes = isPrestaShop const appVolumes = ` volumes:
? ` volumes:
- app_data:/var/www/html`
: ` volumes:
- app_data:/var/www/html - app_data:/var/www/html
- {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`; - {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`;
@@ -129,7 +89,7 @@ async function main() {
app: app:
image: ${t.dockerImage} image: ${t.dockerImage}
ports: ports:
- "127.0.0.1:{PORT}:80" - "{PORT}:80"
environment: environment:
${appEnv} ${appEnv}
INSTANCE_ID: {INSTANCE_ID} INSTANCE_ID: {INSTANCE_ID}
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.