diff --git a/CLAUDE.md b/CLAUDE.md index 82dc86a..b040693 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Next.js 16 App Router application. All pages are server components; interactive **Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts` → `lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `logbuch123` and `mustChangePassword` is forced to `true`. The middleware file exports `proxy` (not `middleware`) — Next.js 16 requirement. -**Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **latin1** — avoid non-ASCII characters in SQL WHERE clauses; use `LIKE 'Ascii%'` prefix patterns instead. +**Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **utf8mb4** (collation `utf8mb4_unicode_ci`); connection pool uses `charset: 'utf8mb4'`. **SQL in JS**: MySQL backticks inside JS template literals cause parse errors. Write complex queries using string concatenation (`+`), not template literals. `LIMIT` cannot be a parameterized placeholder in complex grouped queries — embed it directly after validating: `LIST_SQL + \` LIMIT ${limit}\``. diff --git a/app/api/beos/route.ts b/app/api/beos/route.ts index e4626ed..3ccef69 100644 --- a/app/api/beos/route.ts +++ b/app/api/beos/route.ts @@ -7,7 +7,7 @@ export async function GET() { if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); try { const rows = await query( - 'SELECT id AS ID, `kürzel` AS Kuerzel, CONCAT(IFNULL(vorname, \'\'), IF(vorname IS NOT NULL, \' \', \'\'), name) AS Name FROM beos WHERE `kürzel` IS NOT NULL ORDER BY name ASC' + 'SELECT id AS ID, `kürzel` AS Kuerzel, CONCAT(IFNULL(vorname, \'\'), IF(vorname IS NOT NULL, \' \', \'\'), name) AS Name FROM beos WHERE `kürzel` IS NOT NULL AND FIND_IN_SET(\'guide\', role) > 0 ORDER BY name ASC' ) as { ID: number; Kuerzel: string; Name: string }[]; return NextResponse.json(rows); } catch (error) { diff --git a/app/api/logbuch/[id]/route.ts b/app/api/logbuch/[id]/route.ts index 6f03742..4b20e9c 100644 --- a/app/api/logbuch/[id]/route.ts +++ b/app/api/logbuch/[id]/route.ts @@ -38,7 +38,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ for (const obj of (objekte as SelectedObjekt[]) || []) { let objektId = obj.ID; if (!objektId) { - const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[]; + const existing = await query('SELECT ID, Name FROM objekte WHERE LOWER(Name) = LOWER(?)', [obj.Name]) as { ID: number; Name: string }[]; if (existing[0]) { objektId = existing[0].ID; } else { @@ -50,8 +50,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]); await query( - 'INSERT INTO logbuch_objekte (LogbuchID, ObjektID, ObjektName) VALUES (?, ?, ?)', - [logbuchId, objektId, obj.Name] + 'INSERT INTO logbuch_objekte (LogbuchID, ObjektID) VALUES (?, ?)', + [logbuchId, objektId] ); } diff --git a/app/api/logbuch/route.ts b/app/api/logbuch/route.ts index eb9b0e7..b33a30c 100644 --- a/app/api/logbuch/route.ts +++ b/app/api/logbuch/route.ts @@ -12,11 +12,12 @@ const LIST_SQL = ' l.WetterTemp, l.WetterFeuchte, l.WetterDruck,' + ' l.created_by, l.created_at,' + " GROUP_CONCAT(DISTINCT bk.kuerzel ORDER BY bk.kuerzel SEPARATOR ', ') AS BEOs," + - " GROUP_CONCAT(DISTINCT lo.ObjektName ORDER BY lo.ObjektName SEPARATOR ', ') AS Objekte" + + " GROUP_CONCAT(DISTINCT o.Name ORDER BY o.Name SEPARATOR ', ') AS Objekte" + ' FROM logbuch l' + ' LEFT JOIN logbuch_beos lb ON lb.LogbuchID = l.ID' + ' LEFT JOIN (SELECT id, `kürzel` AS kuerzel FROM beos) bk ON bk.id = lb.BeoID' + ' LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID' + + ' LEFT JOIN objekte o ON o.ID = lo.ObjektID' + ' WHERE l.Kuppel = ?' + ' GROUP BY l.ID' + ' ORDER BY l.Beginn DESC'; @@ -67,7 +68,7 @@ export async function POST(request: NextRequest) { for (const obj of (objekte as SelectedObjekt[]) || []) { let objektId = obj.ID; if (!objektId) { - const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[]; + const existing = await query('SELECT ID, Name FROM objekte WHERE LOWER(Name) = LOWER(?)', [obj.Name]) as { ID: number; Name: string }[]; if (existing[0]) { objektId = existing[0].ID; } else { @@ -77,8 +78,8 @@ export async function POST(request: NextRequest) { } await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]); await query( - 'INSERT INTO logbuch_objekte (LogbuchID, ObjektID, ObjektName) VALUES (?, ?, ?)', - [logbuchId, objektId, obj.Name] + 'INSERT INTO logbuch_objekte (LogbuchID, ObjektID) VALUES (?, ?)', + [logbuchId, objektId] ); } diff --git a/app/change-password/actions.ts b/app/change-password/actions.ts index f0122ad..a263159 100644 --- a/app/change-password/actions.ts +++ b/app/change-password/actions.ts @@ -35,6 +35,7 @@ export async function changePassword( beoName: session.beoName, mustChangePassword: false, isAuthenticated: true, + role: session.role ?? null, }); redirect('/'); diff --git a/app/login/actions.ts b/app/login/actions.ts index 7541532..c99a757 100644 --- a/app/login/actions.ts +++ b/app/login/actions.ts @@ -29,6 +29,7 @@ export async function login( beoName: getBeoDisplayName(result.beo), mustChangePassword: mustChange, isAuthenticated: true, + role: result.beo.role ?? null, }); if (mustChange) { diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..ff3375d --- /dev/null +++ b/compose.yml @@ -0,0 +1,99 @@ +services: + logbuch_mysql: + image: mysql:lts + container_name: logbuch_mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS} + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASS} + volumes: + - db_data:/var/lib/mysql + ports: + - "127.0.0.1:3336:3306" + networks: + - proxy + healthcheck: + test: + - CMD + - mysqladmin + - ping + - -h + - localhost + - -uroot + - -p${DB_ROOT_PASS} + interval: 10s + timeout: 5s + retries: 10 + # Kein Port nach außen — nur internes Netzwerk + + logbuch_phpmyadmin: + image: phpmyadmin:latest + container_name: logbuch_phpmyadmin + restart: unless-stopped + environment: + PMA_HOST: logbuch_mysql + PMA_PORT: 3306 + PMA_ABSOLUTE_URI: https://logbuch.fuerst-stuttgart.de/myadmin/ + depends_on: + logbuch_mysql: + condition: service_healthy + labels: + - traefik.enable=true + - traefik.http.routers.logbuch-pma.entrypoints=http + - traefik.http.routers.logbuch-pma.rule=Host(`logbuch.fuerst-stuttgart.de`) && PathPrefix(`/myadmin`) + - traefik.http.middlewares.logbuch-pma-https-redirect.redirectscheme.scheme=https + - traefik.http.routers.logbuch-pma.middlewares=logbuch-pma-https-redirect + - traefik.http.routers.logbuch-pma-secure.entrypoints=https + - traefik.http.routers.logbuch-pma-secure.rule=Host(`logbuch.fuerst-stuttgart.de`) && PathPrefix(`/myadmin`) + - traefik.http.routers.logbuch-pma-secure.tls=true + - traefik.http.routers.logbuch-pma-secure.middlewares=logbuch-pma-slash,logbuch-pma-strip + - traefik.http.middlewares.logbuch-pma-slash.redirectregex.regex=^https://logbuch\.fuerst-stuttgart\.de/myadmin$$ + - traefik.http.middlewares.logbuch-pma-slash.redirectregex.replacement=https://logbuch.fuerst-stuttgart.de/myadmin/ + - traefik.http.middlewares.logbuch-pma-strip.stripprefix.prefixes=/myadmin + - traefik.http.routers.logbuch-pma-secure.service=logbuch-pma + - traefik.http.services.logbuch-pma.loadbalancer.server.port=80 + networks: + - proxy + + logbuch_app: + image: docker.citysensor.de/logbuch:latest + container_name: logbuch_app + restart: unless-stopped + environment: + DB_HOST: logbuch_mysql + DB_USER: ${DB_USER} + DB_PASS: ${DB_PASS} + DB_NAME: ${DB_NAME} + DB_PORT: 3306 + AUTH_SECRET: ${AUTH_SECRET} + NODE_ENV: production + ports: + - 127.0.0.1:${APP_PORT:-3000}:3000 + depends_on: + logbuch_mysql: + condition: service_healthy + labels: + - traefik.enable=true + - traefik.http.routers.logbuch.entrypoints=http + - traefik.http.routers.logbuch.rule=Host(`logbuch.fuerst-stuttgart.de`) + - traefik.http.middlewares.logbuch-https-redirect.redirectscheme.scheme=https + - traefik.http.routers.logbuch.middlewares=logbuch-https-redirect + - traefik.http.routers.logbuch-secure.entrypoints=https + - traefik.http.routers.logbuch-secure.rule=Host(`logbuch.fuerst-stuttgart.de`) + - traefik.http.routers.logbuch-secure.tls=true + - traefik.http.routers.logbuch-secure.service=logbuch + - traefik.http.services.logbuch.loadbalancer.server.port=3000 + networks: + - proxy + - gitea-internal +networks: + proxy: + name: dockge_default + external: true + gitea-internal: + name: gitea_gitea-internal + external: true +volumes: + db_data: null diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 24d0179..a775479 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -20,6 +20,22 @@ services: retries: 10 # Kein Port nach außen — nur internes Netzwerk + logbuch_phpmyadmin: + image: phpmyadmin:latest + container_name: logbuch_phpmyadmin + restart: unless-stopped + environment: + PMA_HOST: logbuch_mysql + PMA_PORT: 3306 + PMA_ABSOLUTE_URI: https://logbuch.fuerst-stuttgart.de/myadmin/ + ports: + - "127.0.0.1:${PMA_PORT:-8080}:80" + depends_on: + logbuch_mysql: + condition: service_healthy + networks: + - logbuch_net + logbuch_app: image: docker.citysensor.de/logbuch:latest container_name: logbuch_app diff --git a/lib/auth.ts b/lib/auth.ts index ba0334a..33a09be 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -8,11 +8,12 @@ export interface Beo { kürzel: string | null; pw: string | null; MustChangePassword: number; + role: string | null; } export async function getBeoByKuerzel(kuerzel: string): Promise { const rows = await query( - 'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword FROM beos WHERE `kürzel` = ?', + 'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword, role FROM beos WHERE `kürzel` = ?', [kuerzel] ) as Beo[]; return rows[0] ?? null; diff --git a/lib/db.ts b/lib/db.ts index 444113d..dd8bef3 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -7,6 +7,7 @@ const dbConfig = { user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME || 'logbuch', + charset: 'utf8mb4', waitForConnections: true, connectionLimit: 10, queueLimit: 0, diff --git a/lib/session.ts b/lib/session.ts index dd2908f..4e97e5a 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -14,6 +14,7 @@ export interface SessionData { mustChangePassword: boolean; isAuthenticated: boolean; expiresAt: number; + role: string | null; } async function encrypt(payload: SessionData): Promise { diff --git a/migrate_to_utf8mb4.sh b/migrate_to_utf8mb4.sh new file mode 100755 index 0000000..071c457 --- /dev/null +++ b/migrate_to_utf8mb4.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# Migriert die Datenbank von latin1 auf utf8mb4. +# Läuft auf dem Server im Verzeichnis der compose.yml. +# Voraussetzung: iconv installiert (apt install libc-bin) + +set -e + +CONTAINER="logbuch_mysql" +DB="sternwarte" +DUMP_LATIN1="/tmp/sternwarte_latin1.sql" +DUMP_UTF8="/tmp/sternwarte_utf8mb4.sql" + +# Root-Passwort aus .env lesen +ROOT_PASS=$(grep DB_ROOT_PASS .env | cut -d= -f2) +if [ -z "$ROOT_PASS" ]; then + echo "FEHLER: DB_ROOT_PASS nicht in .env gefunden." >&2 + exit 1 +fi + +echo "══════════════════════════════════════════════════════" +echo " latin1 → utf8mb4 Migration: $DB" +echo "══════════════════════════════════════════════════════" +echo "" + +# ── Sicherheits-Backup ─────────────────────────────────────────────────────── +BACKUP="/tmp/sternwarte_backup_$(date +%Y%m%d_%H%M%S).sql" +echo ">>> Erstelle Backup: $BACKUP" +docker exec "$CONTAINER" mysqldump \ + -u root -p"$ROOT_PASS" \ + --default-character-set=latin1 \ + --single-transaction \ + --no-tablespaces \ + --set-gtid-purged=OFF \ + "$DB" > "$BACKUP" +echo " Backup gespeichert: $BACKUP" +echo "" + +# ── Dump als latin1 exportieren ────────────────────────────────────────────── +echo ">>> Exportiere Daten mit latin1-Zeichensatz..." +docker exec "$CONTAINER" mysqldump \ + -u root -p"$ROOT_PASS" \ + --default-character-set=latin1 \ + --single-transaction \ + --no-tablespaces \ + --skip-set-charset \ + --set-gtid-purged=OFF \ + "$DB" > "$DUMP_LATIN1" +echo " Dump: $DUMP_LATIN1" +echo "" + +# ── Bytes latin1 → utf8 konvertieren ──────────────────────────────────────── +echo ">>> Konvertiere Bytes: latin1 → utf8..." +iconv -f latin1 -t utf8 "$DUMP_LATIN1" > "$DUMP_UTF8" + +# Charset-Deklarationen im SQL ersetzen +sed -i \ + -e 's/DEFAULT CHARSET=latin1/DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci/g' \ + -e 's/COLLATE=latin1_swedish_ci//g' \ + -e 's/CHARACTER SET latin1/CHARACTER SET utf8mb4/g' \ + -e 's/COLLATE latin1_swedish_ci/COLLATE utf8mb4_unicode_ci/g' \ + "$DUMP_UTF8" + +echo " Konvertiert: $DUMP_UTF8" +echo "" + +# ── Zeilenzähler vor Migration ─────────────────────────────────────────────── +echo ">>> Zeilenzähler vor Migration:" +for TABLE in beos objekte logbuch logbuch_beos logbuch_objekte; do + COUNT=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \ + -e "SELECT COUNT(*) FROM $TABLE;" "$DB" 2>/dev/null || echo "n/a") + printf " %-25s %5s Zeilen\n" "$TABLE" "$COUNT" +done +echo "" + +# ── Zieldatenbank anlegen ──────────────────────────────────────────────────── +echo ">>> Lege Zieldatenbank an (utf8mb4)..." +docker exec -i "$CONTAINER" mysql -u root -p"$ROOT_PASS" <>> Importiere utf8mb4-Daten..." +docker exec -i "$CONTAINER" mysql \ + -u root -p"$ROOT_PASS" \ + --default-character-set=utf8mb4 \ + "${DB}_new" < "$DUMP_UTF8" + +# ── Zeilenzähler nach Migration ────────────────────────────────────────────── +echo "" +echo ">>> Zeilenzähler nach Migration:" +ALL_OK=true +for TABLE in beos objekte logbuch logbuch_beos logbuch_objekte; do + BEFORE=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \ + -e "SELECT COUNT(*) FROM $TABLE;" "${DB}" 2>/dev/null || echo "?") + AFTER=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \ + -e "SELECT COUNT(*) FROM $TABLE;" "${DB}_new" 2>/dev/null || echo "?") + STATUS="✓" + [ "$BEFORE" != "$AFTER" ] && STATUS="✗ ABWEICHUNG" && ALL_OK=false + printf " %-25s %5s → %5s %s\n" "$TABLE" "$BEFORE" "$AFTER" "$STATUS" +done +echo "" + +if [ "$ALL_OK" = false ]; then + echo "FEHLER: Zeilenzähler stimmen nicht überein!" >&2 + echo "Datenbank '${DB}_new' bleibt zur manuellen Prüfung erhalten." >&2 + exit 1 +fi + +# ── Swap: sternwarte → sternwarte_old, sternwarte_new → sternwarte ─────────── +echo ">>> Alle Zeilenzähler stimmen — tausche Datenbanken..." + +TABLES=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \ + -e "SHOW TABLES;" "${DB}_new") + +# Alte DB umbenennen (Tabellen nach sternwarte_old verschieben) +docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \ + "CREATE DATABASE IF NOT EXISTS ${DB}_old CHARACTER SET latin1;" +for TABLE in $TABLES; do + docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \ + "RENAME TABLE \`${DB}\`.\`$TABLE\` TO \`${DB}_old\`.\`$TABLE\`;" +done + +# Neue DB nach sternwarte verschieben +docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \ + "ALTER DATABASE ${DB} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +for TABLE in $TABLES; do + docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \ + "RENAME TABLE \`${DB}_new\`.\`$TABLE\` TO \`${DB}\`.\`$TABLE\`;" +done + +docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \ + "DROP DATABASE ${DB}_new; DROP DATABASE ${DB}_old;" + +# ── Kollation bestätigen ───────────────────────────────────────────────────── +echo "" +echo ">>> Kollation der Tabellen:" +docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \ + "SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES + WHERE TABLE_SCHEMA = '$DB' ORDER BY TABLE_NAME;" 2>/dev/null + +echo "" +echo "══════════════════════════════════════════════════════" +echo " Migration erfolgreich abgeschlossen!" +echo " Backup: $BACKUP" +echo " App neu starten: docker compose up -d logbuch_app" +echo "══════════════════════════════════════════════════════"