commit 013eb78a7ccfc103534f32a09cfcc37464605f32 Author: Sebastian Zell Date: Fri Jan 16 10:14:11 2026 +0100 Initial commit diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..4d7d243 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,259 @@ +# Deployment - PointCab Webexport Server + +Diese Anleitung beschreibt den Deployment-Prozess für Updates und neue Versionen. + +## 📋 Voraussetzungen + +- Server bereits installiert (siehe [INSTALLATION.md](INSTALLATION.md)) +- SSH-Zugang zum Server +- PM2 läuft + +## 🚀 Standard-Deployment + +### Methode 1: Automatisches Deployment (empfohlen) + +```bash +# Auf dem Server +cd /var/www/pointcab_webexport_server + +# Deployment-Script ausführen +sudo ./scripts/deploy.sh +``` + +### Methode 2: Manuelles Deployment + +```bash +# 1. Zum Projekt wechseln +cd /var/www/pointcab_webexport_server/nodejs_space + +# 2. Aktuelle Version sichern +BACKUP_DIR="/var/www/pointcab_webexport_server/backups/$(date +%Y%m%d_%H%M%S)" +mkdir -p "$BACKUP_DIR" +cp -r src/ "$BACKUP_DIR/" +cp -r dist/ "$BACKUP_DIR/" + +# 3. Neue Dateien kopieren (via Git oder SCP) +git pull origin main +# ODER +# scp -r neue_dateien/* user@server:/var/www/pointcab_webexport_server/nodejs_space/ + +# 4. Abhängigkeiten aktualisieren +npm install + +# 5. TypeScript kompilieren +npm run build + +# 6. PM2 neustarten +pm2 restart pointcab-server + +# 7. Status prüfen +pm2 status +curl http://localhost:3000/health +``` + +## ⚙️ Umgebungsvariablen + +### Erforderliche Variablen + +| Variable | Beschreibung | Beispiel | +|----------|--------------|----------| +| `PORT` | Server-Port | `3000` | +| `NODE_ENV` | Umgebung | `production` | +| `DATABASE_URL` | PostgreSQL-Verbindung | `postgresql://user:pass@localhost:5432/db` | +| `UPLOAD_DIR` | Upload-Verzeichnis | `/var/www/.../uploads` | +| `SESSION_SECRET` | Session-Verschlüsselung | `mindestens-32-zeichen` | +| `ADMIN_PASSWORD` | Admin-Zugang | `sicheres-passwort` | + +### .env-Beispiel + +```env +PORT=3000 +NODE_ENV=production +DATABASE_URL="postgresql://pointcab_user:password@localhost:5432/pointcab_db" +UPLOAD_DIR=/var/www/pointcab_webexport_server/nodejs_space/uploads +SESSION_SECRET=ihr-geheimer-session-schluessel-mindestens-32-zeichen +ADMIN_PASSWORD=IhrAdminPasswort +``` + +## 🗄️ Datenbank-Migration + +### Bei Schema-Änderungen + +```bash +cd /var/www/pointcab_webexport_server/nodejs_space + +# Prisma-Client neu generieren +npx prisma generate + +# Schema anwenden (ohne Datenverlust) +npx prisma db push + +# ODER: Migration erstellen und anwenden +npx prisma migrate deploy +``` + +### Datenbank-Schema prüfen + +```bash +# Aktuelles Schema anzeigen +npx prisma studio + +# Oder via SQL +sudo -u postgres psql -d pointcab_db -c "\d project" +``` + +## 🔄 PM2-Konfiguration + +### ecosystem.config.js erstellen (Optional) + +```bash +cat > /var/www/pointcab_webexport_server/nodejs_space/ecosystem.config.js << 'EOF' +module.exports = { + apps: [{ + name: 'pointcab-server', + script: './dist/main.js', + instances: 1, + exec_mode: 'fork', + autorestart: true, + watch: false, + max_memory_restart: '1G', + env_production: { + NODE_ENV: 'production', + PORT: 3000 + } + }] +}; +EOF +``` + +### PM2-Befehle + +```bash +# Mit ecosystem.config.js starten +pm2 start ecosystem.config.js --env production + +# Status +pm2 status + +# Logs +pm2 logs pointcab-server + +# Neustart +pm2 restart pointcab-server + +# Stop +pm2 stop pointcab-server + +# Löschen +pm2 delete pointcab-server + +# Autostart speichern +pm2 save +``` + +## 🌐 Nginx Proxy Manager Setup + +### Proxy Host Konfiguration + +1. **Nginx Proxy Manager öffnen:** `http://server-ip:81` + +2. **Neuen Proxy Host hinzufügen:** + - Domain Names: `pointcab-webexport.ihre-domain.de` + - Scheme: `http` + - Forward Hostname/IP: `localhost` + - Forward Port: `3000` + - Websockets Support: ☑️ aktivieren + +3. **SSL konfigurieren:** + - SSL Tab öffnen + - Request a new SSL Certificate + - Force SSL: ☑️ aktivieren + - HTTP/2 Support: ☑️ aktivieren + +### Custom Nginx Konfiguration (Optional) + +```nginx +# Größere Uploads erlauben +client_max_body_size 500M; + +# Timeout erhöhen +proxy_read_timeout 300; +proxy_connect_timeout 300; +proxy_send_timeout 300; +``` + +## ✅ Deployment-Checkliste + +### Vor dem Deployment + +- [ ] Backup erstellt +- [ ] Änderungen getestet +- [ ] .env-Datei aktuell + +### Nach dem Deployment + +- [ ] PM2 läuft (`pm2 status`) +- [ ] Health-Check erfolgreich (`curl localhost:3000/health`) +- [ ] Admin-Dashboard erreichbar +- [ ] Upload-Funktion getestet +- [ ] Logs geprüft (`pm2 logs`) + +## 🔙 Rollback + +### Bei Problemen + +```bash +# Letztes Backup finden +ls -la /var/www/pointcab_webexport_server/backups/ + +# Backup wiederherstellen +BACKUP="/var/www/pointcab_webexport_server/backups/YYYYMMDD_HHMMSS" +cp -r "$BACKUP/src/" /var/www/pointcab_webexport_server/nodejs_space/ +cp -r "$BACKUP/dist/" /var/www/pointcab_webexport_server/nodejs_space/ + +# Server neustarten +pm2 restart pointcab-server +``` + +## 📊 Monitoring + +### Logs überwachen + +```bash +# Echtzeit-Logs +pm2 logs pointcab-server + +# Letzte 100 Zeilen +pm2 logs pointcab-server --lines 100 + +# Fehler-Logs +pm2 logs pointcab-server --err +``` + +### System-Ressourcen + +```bash +# PM2 Monitoring +pm2 monit + +# Speicherverbrauch +pm2 info pointcab-server +``` + +## 🔒 Sicherheit beim Deployment + +1. **Keine Secrets im Repository:** + - `.env` ist in `.gitignore` + - Passwörter niemals committen + +2. **Backups vor Updates:** + - Immer Backup erstellen + - Backup-Pfad dokumentieren + +3. **Test vor Produktion:** + - Änderungen lokal testen + - Staging-Umgebung nutzen (wenn vorhanden) + +--- + +**Siehe auch:** [MAINTENANCE.md](MAINTENANCE.md) für Wartungsaufgaben diff --git a/GITEA_WORKFLOW.md b/GITEA_WORKFLOW.md new file mode 100644 index 0000000..0737661 --- /dev/null +++ b/GITEA_WORKFLOW.md @@ -0,0 +1,376 @@ +# Gitea Workflow - Best Practices + +Diese Anleitung beschreibt den empfohlenen Git-Workflow für das PointCab Webexport Projekt. + +## 🏗️ Repository erstellen + +### In Gitea + +1. **Anmelden** auf Ihrem Gitea-Server +2. **"+" → "Neues Repository"** +3. **Einstellungen:** + - Name: `pointcab-webexport` + - Beschreibung: `PointCab Webexport Server - Webbasiertes Sharing-System` + - Sichtbarkeit: Privat (oder Öffentlich) + - README initialisieren: Nein (wir haben bereits eine) + - .gitignore: Keine (wir haben bereits eine) + - Lizenz: MIT + +### Lokales Repository verbinden + +```bash +cd /home/ubuntu/pointcab_webexport_git + +# Git initialisieren +git init + +# Remote hinzufügen +git remote add origin https://ihr-gitea-server/username/pointcab-webexport.git + +# Ersten Commit erstellen +git add . +git commit -m "Initial commit: PointCab Webexport Server" + +# Auf Gitea pushen +git push -u origin main +``` + +## 🌿 Branch-Strategie + +### Haupt-Branches + +| Branch | Zweck | Schutz | +|--------|-------|--------| +| `main` | Produktions-Code | Geschützt | +| `develop` | Entwicklung | Optional geschützt | + +### Feature-Branches + +``` +feature/neue-funktion +feature/upload-verbesserung +feature/multi-html-support +``` + +### Bugfix-Branches + +``` +bugfix/404-fehler +bugfix/passwort-problem +hotfix/kritischer-fehler +``` + +### Branch-Workflow + +``` +main ─────────────────────────────────────────► + ↑ ↑ + │ │ +develop ──●────●─────●────────●────► + │ ↑ │ ↑ + │ │ │ │ +feature/a ─●───┘ │ │ + │ │ +feature/b ───────────●────────┘ +``` + +## 📝 Commit-Konventionen + +### Format + +``` +(): + + + + +``` + +### Typen + +| Typ | Beschreibung | +|-----|--------------| +| `feat` | Neue Funktion | +| `fix` | Bugfix | +| `docs` | Dokumentation | +| `style` | Formatierung | +| `refactor` | Code-Verbesserung | +| `test` | Tests | +| `chore` | Wartung | + +### Beispiele + +```bash +# Neue Funktion +git commit -m "feat(upload): Multi-HTML-Datei-Auswahl hinzugefügt" + +# Bugfix +git commit -m "fix(assets): 404-Fehler bei Subfolder-Assets behoben" + +# Dokumentation +git commit -m "docs: Installationsanleitung aktualisiert" + +# Refactoring +git commit -m "refactor(service): Asset-Pfad-Auflösung verbessert" +``` + +## 🔀 Pull Requests + +### PR erstellen + +1. **Branch erstellen:** + ```bash + git checkout develop + git pull + git checkout -b feature/neue-funktion + ``` + +2. **Änderungen machen:** + ```bash + # Code ändern... + git add . + git commit -m "feat: Neue Funktion implementiert" + ``` + +3. **Branch pushen:** + ```bash + git push -u origin feature/neue-funktion + ``` + +4. **PR in Gitea erstellen:** + - Ziel-Branch: `develop` (oder `main` für Hotfixes) + - Beschreibung mit Änderungen + - Reviewer zuweisen (falls vorhanden) + +### PR-Checkliste + +- [ ] Code getestet +- [ ] Dokumentation aktualisiert +- [ ] Keine Secrets im Code +- [ ] Commit-Messages korrekt +- [ ] Ziel-Branch korrekt + +### PR mergen + +1. **Review (falls vorhanden)** +2. **Merge in Gitea:** + - "Squash and Merge" für saubere Historie + - Oder "Merge Commit" für vollständige Historie +3. **Branch löschen** (optional) + +## 🏷️ Releases + +### Versionierung (Semantic Versioning) + +``` +MAJOR.MINOR.PATCH + +1.0.0 → 1.0.1 (Bugfix) +1.0.1 → 1.1.0 (Neue Funktion) +1.1.0 → 2.0.0 (Breaking Change) +``` + +### Release erstellen + +1. **Version aktualisieren:** + ```bash + # package.json Version ändern + npm version patch # oder minor/major + ``` + +2. **Changelog aktualisieren:** + ```bash + # docs/CHANGELOG.md bearbeiten + ``` + +3. **Tag erstellen:** + ```bash + git tag -a v1.0.0 -m "Release v1.0.0" + git push origin v1.0.0 + ``` + +4. **In Gitea:** + - Releases → Neues Release + - Tag auswählen: `v1.0.0` + - Beschreibung mit Änderungen + +### Release-Notes Template + +```markdown +## v1.0.0 (2026-01-16) + +### Neue Funktionen +- Multi-HTML-Datei-Unterstützung +- RAR-Entpacken auf dem Server + +### Bugfixes +- 404-Fehler bei Subfolder-Assets behoben +- Passwort-Speicherung korrigiert + +### Verbesserungen +- Performance-Optimierung beim Upload +- Bessere Fehlermeldungen + +### Breaking Changes +- Keine +``` + +## 🔄 Typischer Workflow + +### Neue Funktion entwickeln + +```bash +# 1. Develop aktualisieren +git checkout develop +git pull + +# 2. Feature-Branch erstellen +git checkout -b feature/neue-funktion + +# 3. Entwickeln und committen +# ... code ändern ... +git add . +git commit -m "feat: Neue Funktion - Teil 1" + +# ... weiter entwickeln ... +git add . +git commit -m "feat: Neue Funktion - Teil 2" + +# 4. Branch pushen +git push -u origin feature/neue-funktion + +# 5. PR in Gitea erstellen + +# 6. Nach Merge: Branch löschen +git checkout develop +git pull +git branch -d feature/neue-funktion +``` + +### Bugfix (normal) + +```bash +git checkout develop +git pull +git checkout -b bugfix/problem-beschreibung + +# Fix implementieren +git add . +git commit -m "fix: Problem beschreibung behoben" +git push -u origin bugfix/problem-beschreibung + +# PR erstellen → develop +``` + +### Hotfix (kritisch) + +```bash +git checkout main +git pull +git checkout -b hotfix/kritischer-fehler + +# Fix implementieren +git add . +git commit -m "fix: Kritischer Fehler behoben" +git push -u origin hotfix/kritischer-fehler + +# PR erstellen → main (und develop!) +``` + +## ⚙️ CI/CD (Optional) + +### Gitea Actions (falls verfügbar) + +Erstellen Sie `.gitea/workflows/ci.yml`: + +```yaml +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: | + cd nodejs_space + npm install + + - name: Build + run: | + cd nodejs_space + npm run build + + - name: Lint (optional) + run: | + cd nodejs_space + npm run lint +``` + +### Manuelles Deployment + +Nach erfolgreichem Merge in `main`: + +```bash +# Auf dem Server +cd /var/www/pointcab_webexport_server +git pull origin main +cd nodejs_space +npm install +npm run build +pm2 restart pointcab-server +``` + +## 📋 Best Practices Zusammenfassung + +1. **Niemals direkt auf `main` pushen** +2. **Immer über Pull Requests arbeiten** +3. **Aussagekräftige Commit-Messages** +4. **Regelmäßig `develop` aktualisieren** +5. **Feature-Branches klein halten** +6. **Branches nach Merge löschen** +7. **Tags für Releases verwenden** +8. **Changelog pflegen** + +## 🔗 Nützliche Git-Befehle + +```bash +# Status +git status +git log --oneline -10 + +# Branches +git branch -a +git checkout -b neuer-branch + +# Remote +git remote -v +git fetch --all + +# Stash (temporär speichern) +git stash +git stash pop + +# Rebase (Historie aufräumen) +git rebase -i HEAD~3 + +# Diff +git diff +git diff develop +``` + +--- + +**Weitere Dokumentation:** [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..23edfb4 --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,290 @@ +# Installation - PointCab Webexport Server + +Diese Anleitung beschreibt die komplette Installation auf einem frischen Ubuntu 24.04 Server. + +## 📋 Systemanforderungen + +| Anforderung | Minimum | Empfohlen | +|-------------|---------|-----------| +| OS | Ubuntu 24.04 LTS | Ubuntu 24.04 LTS | +| RAM | 2 GB | 4 GB | +| CPU | 2 Kerne | 4 Kerne | +| Speicher | 20 GB | 50 GB+ | +| Netzwerk | Öffentliche IP | Öffentliche IP + Domain | + +## 🔧 Schritt 1: System vorbereiten + +```bash +# System aktualisieren +sudo apt update && sudo apt upgrade -y + +# Grundlegende Pakete installieren +sudo apt install -y curl wget git unzip build-essential +``` + +## 🐘 Schritt 2: PostgreSQL installieren + +```bash +# PostgreSQL installieren +sudo apt install -y postgresql postgresql-contrib + +# PostgreSQL starten und aktivieren +sudo systemctl start postgresql +sudo systemctl enable postgresql + +# PostgreSQL-Version prüfen +psql --version +``` + +### Datenbank und Benutzer erstellen + +```bash +# Als postgres-Benutzer anmelden +sudo -u postgres psql + +# In der PostgreSQL-Shell: +CREATE USER pointcab_user WITH PASSWORD 'IhrSicheresPasswort'; +CREATE DATABASE pointcab_db OWNER pointcab_user; +GRANT ALL PRIVILEGES ON DATABASE pointcab_db TO pointcab_user; +\q +``` + +## 🟢 Schritt 3: Node.js installieren + +```bash +# NodeSource Repository hinzufügen (Node.js 18.x LTS) +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - + +# Node.js installieren +sudo apt install -y nodejs + +# Version prüfen +node --version # sollte v18.x.x zeigen +npm --version +``` + +## 📦 Schritt 4: Projekt installieren + +```bash +# Verzeichnis erstellen +sudo mkdir -p /var/www/pointcab_webexport_server +sudo chown $USER:$USER /var/www/pointcab_webexport_server + +# In das Verzeichnis wechseln +cd /var/www/pointcab_webexport_server + +# Repository klonen (oder Dateien kopieren) +# git clone https://your-gitea-server/pointcab-webexport.git . +# ODER: Dateien manuell kopieren + +# nodejs_space-Ordner verwenden +cd nodejs_space + +# Abhängigkeiten installieren +npm install +``` + +## ⚙️ Schritt 5: Umgebungsvariablen konfigurieren + +```bash +# .env-Datei erstellen +cat > /var/www/pointcab_webexport_server/nodejs_space/.env << 'EOF' +# Server-Konfiguration +PORT=3000 +NODE_ENV=production + +# Datenbank-Verbindung +DATABASE_URL="postgresql://pointcab_user:IhrSicheresPasswort@localhost:5432/pointcab_db" + +# Upload-Verzeichnis +UPLOAD_DIR=/var/www/pointcab_webexport_server/nodejs_space/uploads + +# Session-Secret (ändern Sie dies!) +SESSION_SECRET=ihr-geheimer-session-schluessel-mindestens-32-zeichen + +# Admin-Passwort (ändern Sie dies!) +ADMIN_PASSWORD=IhrAdminPasswort +EOF +``` + +**Wichtig:** Ändern Sie alle Passwörter und Secrets! + +## 🗄️ Schritt 6: Datenbank migrieren + +```bash +cd /var/www/pointcab_webexport_server/nodejs_space + +# Prisma-Client generieren +npx prisma generate + +# Datenbank-Schema anwenden +npx prisma db push + +# (Optional) Prisma Studio zur DB-Inspektion +# npx prisma studio +``` + +## 🔨 Schritt 7: Projekt kompilieren + +```bash +cd /var/www/pointcab_webexport_server/nodejs_space + +# TypeScript kompilieren +npm run build + +# Prüfen ob dist/ erstellt wurde +ls -la dist/ +``` + +## 🚀 Schritt 8: PM2 installieren und konfigurieren + +```bash +# PM2 global installieren +sudo npm install -g pm2 + +# Anwendung starten +cd /var/www/pointcab_webexport_server/nodejs_space +pm2 start dist/main.js --name pointcab-server + +# Status prüfen +pm2 status + +# Logs anzeigen +pm2 logs pointcab-server + +# PM2 Autostart einrichten +pm2 startup +pm2 save +``` + +## 🔍 Schritt 9: Installation verifizieren + +```bash +# Health-Check +curl http://localhost:3000/health + +# Sollte ausgeben: {"status":"ok"} + +# Admin-Dashboard testen (im Browser) +# http://IHRE-IP:3000/admin/dashboard +``` + +## 🌐 Schritt 10: Nginx Proxy Manager (Optional) + +### Installation via Docker + +```bash +# Docker installieren +sudo apt install -y docker.io docker-compose + +# Docker starten +sudo systemctl start docker +sudo systemctl enable docker + +# Nginx Proxy Manager starten +mkdir -p ~/nginx-proxy-manager +cd ~/nginx-proxy-manager + +cat > docker-compose.yml << 'EOF' +version: '3' +services: + app: + image: 'jc21/nginx-proxy-manager:latest' + restart: unless-stopped + ports: + - '80:80' + - '81:81' + - '443:443' + volumes: + - ./data:/data + - ./letsencrypt:/etc/letsencrypt +EOF + +sudo docker-compose up -d +``` + +### Nginx Proxy Manager konfigurieren + +1. Öffnen Sie `http://IHRE-IP:81` +2. Standard-Login: `admin@example.com` / `changeme` +3. Passwort ändern +4. Neuen Proxy Host hinzufügen: + - Domain: `pointcab-webexport.ihre-domain.de` + - Forward Host: `localhost` + - Forward Port: `3000` + - SSL aktivieren (Let's Encrypt) + +## 📁 Upload-Verzeichnis erstellen + +```bash +mkdir -p /var/www/pointcab_webexport_server/nodejs_space/uploads +chmod 755 /var/www/pointcab_webexport_server/nodejs_space/uploads +``` + +## 🔒 Sicherheitshinweise + +1. **Firewall konfigurieren:** + ```bash + sudo ufw allow 22/tcp # SSH + sudo ufw allow 80/tcp # HTTP + sudo ufw allow 443/tcp # HTTPS + sudo ufw enable + ``` + +2. **Passwörter ändern:** + - PostgreSQL-Passwort + - Admin-Passwort + - Session-Secret + +3. **Backups einrichten:** + ```bash + # Datenbank-Backup + pg_dump -U pointcab_user pointcab_db > backup.sql + ``` + +## ✅ Checkliste + +- [ ] Ubuntu 24.04 installiert +- [ ] System aktualisiert +- [ ] PostgreSQL installiert und konfiguriert +- [ ] Node.js 18.x installiert +- [ ] Projekt-Dateien kopiert +- [ ] .env konfiguriert +- [ ] Datenbank migriert +- [ ] Projekt kompiliert +- [ ] PM2 konfiguriert +- [ ] (Optional) Nginx Proxy Manager konfiguriert +- [ ] Health-Check erfolgreich + +## 🆘 Fehlerbehebung + +### PostgreSQL-Verbindungsfehler +```bash +# PostgreSQL-Status prüfen +sudo systemctl status postgresql + +# Logs prüfen +sudo tail -f /var/log/postgresql/postgresql-*-main.log +``` + +### PM2-Fehler +```bash +# Logs anzeigen +pm2 logs pointcab-server --lines 50 + +# Neustart +pm2 restart pointcab-server +``` + +### Port bereits belegt +```bash +# Prozess auf Port 3000 finden +sudo lsof -i :3000 + +# Prozess beenden +sudo kill -9 +``` + +--- + +**Nächster Schritt:** [DEPLOYMENT.md](DEPLOYMENT.md) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1db4e25 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PointCab Webexport + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MAINTENANCE.md b/MAINTENANCE.md new file mode 100644 index 0000000..48d920f --- /dev/null +++ b/MAINTENANCE.md @@ -0,0 +1,345 @@ +# Wartung & Bereinigung - PointCab Webexport Server + +Diese Anleitung beschreibt regelmäßige Wartungsaufgaben und Bereinigung. + +## 📋 Übersicht - Was behalten, was löschen? + +### ✅ Behalten (wichtig!) + +| Verzeichnis/Datei | Grund | +|-------------------|-------| +| `/var/www/pointcab_webexport_server/nodejs_space/` | Server-Code | +| `/var/www/pointcab_webexport_server/nodejs_space/uploads/` | Projekt-Dateien | +| `/var/www/pointcab_webexport_server/nodejs_space/.env` | Konfiguration | +| `/var/www/pointcab_webexport_server/nodejs_space/prisma/` | DB-Schema | +| PostgreSQL `pointcab_db` | Produktions-Datenbank | + +### 🗑️ Kann gelöscht werden + +| Verzeichnis/Datei | Grund | +|-------------------|-------| +| `/var/www/pointcab_webexport_server/backups/` (alt) | Alte Backups | +| `*.log` Dateien | Logs | +| `/tmp/*` | Temporäre Dateien | +| Test-Datenbanken | Nicht benötigt | +| Alte `dist/` Backups | Nach erfolgreichem Deployment | + +## 🧹 Systembereinigung + +### Automatisches Bereinigungsscript + +```bash +cd /var/www/pointcab_webexport_server +sudo ./scripts/cleanup.sh +``` + +### Manuelle Bereinigung + +#### 1. Alte Backups löschen + +```bash +# Backups älter als 30 Tage löschen +find /var/www/pointcab_webexport_server/backups/ -type d -mtime +30 -exec rm -rf {} \; + +# Oder spezifisches Backup +rm -rf /var/www/pointcab_webexport_server/backups/20260101_120000 +``` + +#### 2. Log-Dateien bereinigen + +```bash +# PM2-Logs rotieren +pm2 flush + +# Alte Logs löschen +find /var/log -name "*.log" -mtime +7 -delete + +# PM2-Log-Größe begrenzen (in ecosystem.config.js) +# max_size: '10M' +``` + +#### 3. Temporäre Dateien + +```bash +# Temporäre Upload-Dateien +find /tmp -name "upload_*" -mtime +1 -delete + +# npm Cache leeren +npm cache clean --force +``` + +#### 4. Abgelaufene Projekte + +```bash +# Liste abgelaufener Projekte +cd /var/www/pointcab_webexport_server/nodejs_space +sudo -u postgres psql -d pointcab_db -c "SELECT id, name, shareid, expirydate FROM project WHERE expirydate < NOW();" + +# Abgelaufene Projekte löschen (Vorsicht!) +# Dies muss über das Admin-Dashboard erfolgen +``` + +## 🗄️ Datenbank-Prüfung + +### Datenbank-Prüfungsscript + +```bash +cd /var/www/pointcab_webexport_server +sudo ./scripts/db-check.sh +``` + +### Manuelle Prüfungen + +#### Schema prüfen + +```bash +# Als postgres-Benutzer +sudo -u postgres psql -d pointcab_db + +# Tabellen anzeigen +\dt + +# Schema der project-Tabelle +\d project + +# Beenden +\q +``` + +#### Erwartetes Schema + +``` + Table "public.project" + Column | Type | Collation | Nullable | Default +--------------+-----------------------------+-----------+----------+------------------ + id | text | | not null | + name | text | | not null | + shareid | text | | not null | + password | text | | not null | + htmlfilename | text | | | <-- NULLABLE! + uploaddate | timestamp(3) without time zone | | not null | + expirydate | timestamp(3) without time zone | | | + createdat | timestamp(3) without time zone | | not null | +``` + +**Wichtig:** `htmlfilename` muss **nullable** sein für Multi-HTML-Unterstützung! + +#### Benutzer prüfen + +```bash +# Alle PostgreSQL-Benutzer +sudo -u postgres psql -c "\du" + +# Berechtigungen prüfen +sudo -u postgres psql -d pointcab_db -c "SELECT * FROM pg_roles WHERE rolname = 'pointcab_user';" +``` + +#### Datenbankgröße + +```bash +# Größe aller Datenbanken +sudo -u postgres psql -c "SELECT pg_database.datname, pg_size_pretty(pg_database_size(pg_database.datname)) FROM pg_database;" + +# Größe der Tabellen +sudo -u postgres psql -d pointcab_db -c "SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC;" +``` + +## 🗑️ Alte Datenbanken löschen + +### Test-Datenbanken identifizieren + +```bash +# Alle Datenbanken auflisten +sudo -u postgres psql -c "\l" +``` + +### Nicht benötigte Datenbanken löschen + +```bash +# Beispiel: Test-Datenbank löschen +sudo -u postgres psql -c "DROP DATABASE IF EXISTS test_db;" + +# VORSICHT: Niemals pointcab_db löschen! +``` + +### Produktions-Datenbanken + +**Behalten:** +- `pointcab_db` (Produktions-Datenbank) +- `postgres` (System-Datenbank) + +**Kann gelöscht werden:** +- `test_*` Datenbanken +- Alte Entwicklungs-Datenbanken + +## 📊 Backup-Strategie + +### Datenbank-Backup + +```bash +# Komplettes Backup +sudo -u postgres pg_dump pointcab_db > /var/www/pointcab_webexport_server/backups/db_$(date +%Y%m%d).sql + +# Mit Komprimierung +sudo -u postgres pg_dump pointcab_db | gzip > /var/www/pointcab_webexport_server/backups/db_$(date +%Y%m%d).sql.gz +``` + +### Uploads-Backup + +```bash +# Uploads sichern +tar -czf /var/www/pointcab_webexport_server/backups/uploads_$(date +%Y%m%d).tar.gz \ + /var/www/pointcab_webexport_server/nodejs_space/uploads/ +``` + +### Automatisches Backup (Cronjob) + +```bash +# Crontab bearbeiten +crontab -e + +# Tägliches Backup um 3:00 Uhr +0 3 * * * /var/www/pointcab_webexport_server/scripts/backup.sh +``` + +### Backup-Script erstellen + +```bash +cat > /var/www/pointcab_webexport_server/scripts/backup.sh << 'EOF' +#!/bin/bash +BACKUP_DIR="/var/www/pointcab_webexport_server/backups" +DATE=$(date +%Y%m%d_%H%M%S) + +# Datenbank +sudo -u postgres pg_dump pointcab_db | gzip > "$BACKUP_DIR/db_$DATE.sql.gz" + +# Uploads +tar -czf "$BACKUP_DIR/uploads_$DATE.tar.gz" \ + -C /var/www/pointcab_webexport_server/nodejs_space uploads/ + +# Alte Backups löschen (älter als 30 Tage) +find "$BACKUP_DIR" -name "*.gz" -mtime +30 -delete + +echo "Backup erstellt: $DATE" +EOF + +chmod +x /var/www/pointcab_webexport_server/scripts/backup.sh +``` + +### Backup wiederherstellen + +```bash +# Datenbank +gunzip -c backup.sql.gz | sudo -u postgres psql pointcab_db + +# Uploads +tar -xzf uploads_backup.tar.gz -C /var/www/pointcab_webexport_server/nodejs_space/ +``` + +## 🔄 Update-Prozess + +### Standard-Update + +1. **Backup erstellen** + ```bash + ./scripts/backup.sh + ``` + +2. **Neue Version holen** + ```bash + git pull origin main + ``` + +3. **Abhängigkeiten aktualisieren** + ```bash + npm install + ``` + +4. **Kompilieren** + ```bash + npm run build + ``` + +5. **Server neustarten** + ```bash + pm2 restart pointcab-server + ``` + +6. **Prüfen** + ```bash + pm2 status + curl localhost:3000/health + ``` + +### Bei Schema-Änderungen + +```bash +# Nach Schema-Änderungen +npx prisma generate +npx prisma db push +npm run build +pm2 restart pointcab-server +``` + +## 📅 Wartungsplan + +### Täglich +- [ ] PM2-Status prüfen: `pm2 status` +- [ ] Logs auf Fehler prüfen: `pm2 logs --err` + +### Wöchentlich +- [ ] Backup prüfen +- [ ] Speicherplatz prüfen: `df -h` +- [ ] Abgelaufene Projekte überprüfen + +### Monatlich +- [ ] Alte Backups löschen +- [ ] Logs rotieren +- [ ] System-Updates: `apt update && apt upgrade` +- [ ] Datenbank-Größe prüfen + +### Vierteljährlich +- [ ] Sicherheits-Updates prüfen +- [ ] SSL-Zertifikat erneuern (falls nicht automatisch) +- [ ] Vollständiger System-Test + +## 🔍 Monitoring-Befehle + +```bash +# Server-Status +pm2 status + +# Ressourcen-Verbrauch +pm2 monit + +# Speicherplatz +df -h + +# RAM-Verbrauch +free -h + +# Aktive Verbindungen +netstat -tlnp | grep 3000 + +# PostgreSQL-Verbindungen +sudo -u postgres psql -c "SELECT count(*) FROM pg_stat_activity;" +``` + +## ⚠️ Warnungen + +1. **Niemals löschen:** + - `/var/www/pointcab_webexport_server/nodejs_space/.env` + - PostgreSQL `pointcab_db` Datenbank + - `uploads/` Verzeichnis mit aktiven Projekten + +2. **Vor dem Löschen:** + - Immer Backup erstellen + - Prüfen ob Dateien noch benötigt werden + +3. **Bei Unsicherheit:** + - Erst verschieben, dann löschen + - Logs aufbewahren bis sicher + +--- + +**Siehe auch:** [DEPLOYMENT.md](DEPLOYMENT.md) für Update-Prozesse diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff64bed --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# PointCab Webexport Server + +Ein webbasiertes System zum Teilen und Anzeigen von PointCab Webexport-Projekten (360°-Panoramen, 3D-Modelle). + +## 🎯 Features + +- **Projekt-Upload:** ZIP/RAR-Archive hochladen und automatisch entpacken +- **Manuelle Projekte:** Leere Projekte erstellen und später befüllen +- **Multi-HTML-Unterstützung:** Automatische Erkennung und Auswahl bei mehreren HTML-Dateien +- **Passwort-Schutz:** Optionaler Passwort-Schutz für Projekte +- **Ablaufdatum:** Projekte können ein Ablaufdatum haben +- **Share-Links:** Eindeutige Share-Links für jedes Projekt +- **Admin-Dashboard:** Verwaltung aller Projekte +- **RAR-Entpacken:** Server-seitiges Entpacken von RAR-Archiven + +## 🛠️ Technologie-Stack + +| Komponente | Technologie | +|------------|-------------| +| Backend | NestJS (TypeScript) | +| Datenbank | PostgreSQL | +| ORM | Prisma | +| Process Manager | PM2 | +| Reverse Proxy | Nginx Proxy Manager | +| OS | Ubuntu 24.04 LTS | + +## 🚀 Quick Start + +### Voraussetzungen +- Ubuntu 24.04 LTS Server +- Root-Zugang +- Domain (optional, aber empfohlen) + +### Installation + +```bash +# Repository klonen +git clone https://your-gitea-server/pointcab-webexport.git +cd pointcab-webexport + +# Installation starten +chmod +x scripts/install.sh +sudo ./scripts/install.sh +``` + +Detaillierte Anleitung: [INSTALLATION.md](INSTALLATION.md) + +## 📖 Dokumentation + +| Dokument | Beschreibung | +|----------|--------------| +| [INSTALLATION.md](INSTALLATION.md) | Komplette Installationsanleitung | +| [DEPLOYMENT.md](DEPLOYMENT.md) | Deployment-Prozess | +| [USER_GUIDE.md](USER_GUIDE.md) | Benutzerhandbuch | +| [MAINTENANCE.md](MAINTENANCE.md) | Wartung und Bereinigung | +| [GITEA_WORKFLOW.md](GITEA_WORKFLOW.md) | Git-Workflow Best Practices | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System-Architektur | +| [docs/CHANGELOG.md](docs/CHANGELOG.md) | Änderungshistorie | + +## 📂 Projektstruktur + +``` +pointcab_webexport_git/ +├── README.md # Diese Datei +├── LICENSE # MIT Lizenz +├── INSTALLATION.md # Installationsanleitung +├── DEPLOYMENT.md # Deployment-Dokumentation +├── USER_GUIDE.md # Benutzerhandbuch +├── MAINTENANCE.md # Wartungsanleitung +├── GITEA_WORKFLOW.md # Git-Workflow +├── .gitignore # Git-Ignorierung +├── nodejs_space/ # Server-Code +│ ├── src/ +│ │ ├── controllers/ # HTTP-Controller +│ │ └── services/ # Business-Logik +│ ├── prisma/ +│ │ └── schema.prisma # Datenbank-Schema +│ ├── package.json +│ └── tsconfig.json +├── scripts/ # Hilfsskripte +│ ├── install.sh # Installation +│ ├── deploy.sh # Deployment +│ ├── cleanup.sh # Bereinigung +│ └── db-check.sh # DB-Prüfung +└── docs/ + ├── ARCHITECTURE.md # Architektur + └── CHANGELOG.md # Änderungshistorie +``` + +## 🔗 Links + +- **Live-Demo:** https://pointcab-webexport.zell-cloud.de +- **Admin-Dashboard:** https://pointcab-webexport.zell-cloud.de/admin/dashboard + +## 📄 Lizenz + +Dieses Projekt ist unter der [MIT Lizenz](LICENSE) lizenziert. + +## 👤 Autor + +Entwickelt für die PointCab Webexport-Infrastruktur. diff --git a/USER_GUIDE.md b/USER_GUIDE.md new file mode 100644 index 0000000..205f0e3 --- /dev/null +++ b/USER_GUIDE.md @@ -0,0 +1,278 @@ +# Benutzerhandbuch - PointCab Webexport Server + +Dieses Handbuch erklärt die Verwendung des PointCab Webexport Servers. + +## 🏠 Admin-Dashboard + +### Zugang + +Öffnen Sie das Admin-Dashboard unter: +``` +https://ihre-domain.de/admin/dashboard +``` + +Oder lokal: +``` +http://localhost:3000/admin/dashboard +``` + +### Übersicht + +Das Dashboard zeigt: +- Liste aller Projekte +- Projekt-Status (aktiv/abgelaufen) +- Aktionen (Bearbeiten, Löschen, Link kopieren) + +## 📤 Projekt hochladen (ZIP/RAR) + +### Schritt 1: Neues Projekt + +1. Klicken Sie auf **"Neues Projekt"** +2. Wählen Sie **"ZIP/RAR hochladen"** + +### Schritt 2: Datei auswählen + +1. Klicken Sie auf **"Datei auswählen"** +2. Wählen Sie Ihre ZIP- oder RAR-Datei +3. Unterstützte Formate: `.zip`, `.rar` +4. Maximale Größe: 500 MB (konfigurierbar) + +### Schritt 3: Projekt-Details + +| Feld | Beschreibung | Pflicht | +|------|--------------|---------| +| Projektname | Eindeutiger Name | Ja | +| Passwort | Optionaler Schutz | Nein | +| Ablaufdatum | Automatisches Löschen | Nein | + +### Schritt 4: Hochladen + +1. Klicken Sie auf **"Hochladen"** +2. Warten Sie bis der Upload abgeschlossen ist +3. Bei mehreren HTML-Dateien: Wählen Sie die Haupt-HTML + +### Ergebnis + +Nach erfolgreichem Upload erhalten Sie: +- **Share-Link:** `https://ihre-domain.de/abc123/view` +- **Share-ID:** `abc123` + +## 📁 Manuelles Projekt erstellen + +### Wann verwenden? + +- Wenn Sie Dateien später hinzufügen möchten +- Für schrittweises Befüllen +- Für RAR-Archive (erst Projekt, dann RAR entpacken) + +### Schritte + +1. Klicken Sie auf **"Neues Projekt"** +2. Wählen Sie **"Manuell erstellen"** +3. Geben Sie den Projektnamen ein +4. Optional: Passwort und Ablaufdatum +5. Klicken Sie auf **"Erstellen"** + +Das Projekt wird mit einer Platzhalter-HTML erstellt. + +## 📦 RAR entpacken + +### Voraussetzung + +- Ein manuell erstelltes Projekt +- RAR-Datei mit dem Webexport + +### Schritte + +1. Öffnen Sie das Projekt im Dashboard +2. Klicken Sie auf **"RAR entpacken"** +3. Wählen Sie die RAR-Datei +4. Klicken Sie auf **"Entpacken"** +5. Der Server entpackt die Datei automatisch + +### Was passiert? + +1. Die Platzhalter-HTML wird gelöscht +2. Die RAR wird entpackt +3. HTML-Dateien werden erkannt +4. Bei mehreren HTMLs: Auswahl-Seite erscheint + +## 🔗 Projekt teilen + +### Share-Link kopieren + +1. Im Dashboard: Klicken Sie auf das **Link-Symbol** 📋 +2. Der Link wird in die Zwischenablage kopiert +3. Teilen Sie den Link + +### Link-Format + +``` +https://ihre-domain.de/{shareId}/view +``` + +Beispiel: +``` +https://pointcab-webexport.zell-cloud.de/xmqqkfs0/view +``` + +## 🔐 Passwort-Schutz + +### Passwort setzen + +**Beim Erstellen:** +1. Geben Sie ein Passwort im Feld "Passwort" ein +2. Das Projekt ist sofort geschützt + +**Nachträglich:** +1. Öffnen Sie das Projekt im Dashboard +2. Klicken Sie auf **"Bearbeiten"** +3. Geben Sie ein neues Passwort ein +4. Speichern Sie + +### Passwort-Eingabe + +Wenn ein Projekt geschützt ist: +1. Besucher sehen eine Passwort-Seite +2. Nach korrekter Eingabe wird das Projekt angezeigt +3. Das Passwort wird im Browser gespeichert (Cookie) + +### Passwort entfernen + +1. Bearbeiten Sie das Projekt +2. Löschen Sie das Passwort-Feld +3. Speichern Sie + +## 📄 Multi-HTML-Auswahl + +### Wann erscheint die Auswahl? + +Wenn ein Projekt **mehrere HTML-Dateien** enthält: +- `Web_0.html` +- `pano.html` +- `index.html` + +### Auswahl-Seite + +1. Besucher sehen eine Liste aller HTML-Dateien +2. Klick auf einen Eintrag öffnet diese HTML +3. Die Auswahl wird gespeichert (Cookie) + +### Haupt-HTML festlegen + +Im Dashboard: +1. Bearbeiten Sie das Projekt +2. Wählen Sie die Haupt-HTML aus der Liste +3. Speichern Sie + +## ⏰ Ablaufdatum + +### Ablaufdatum setzen + +1. Beim Erstellen oder Bearbeiten +2. Wählen Sie ein Datum im Kalender +3. Nach diesem Datum ist das Projekt nicht mehr zugänglich + +### Was passiert bei Ablauf? + +- Besucher sehen eine "Projekt abgelaufen"-Meldung +- Das Projekt bleibt im Dashboard sichtbar +- Sie können das Ablaufdatum verlängern oder entfernen + +## 🗑️ Projekt löschen + +### Im Dashboard + +1. Finden Sie das Projekt +2. Klicken Sie auf **"Löschen"** 🗑️ +3. Bestätigen Sie die Löschung + +### Was wird gelöscht? + +- Datenbank-Eintrag +- Alle hochgeladenen Dateien +- Der Share-Link wird ungültig + +**⚠️ Achtung:** Löschen kann nicht rückgängig gemacht werden! + +## 📊 Projektstruktur + +### Unterstützte Strukturen + +**Standard PointCab Export:** +``` +projekt.zip +├── Web_0.html (oder index.html) +├── Web_0_web/ +│ ├── js/ +│ ├── css/ +│ ├── img/ +│ └── panos/ +``` + +**Multi-HTML Export:** +``` +projekt.zip +├── Web_0.html +├── Web_1.html +├── pano.html +├── Web_0_web/ +│ └── ... +├── Web_1_web/ +│ └── ... +``` + +### Automatische Erkennung + +Der Server erkennt automatisch: +- Haupt-HTML-Datei +- Web-Subfolder (z.B. `Web_0_web/`) +- Asset-Verzeichnisse + +## 🔧 Tipps & Tricks + +### Große Dateien + +- ZIP ist schneller als RAR +- Bei sehr großen Projekten: Manuell + RAR + +### Mehrere HTML-Dateien + +- Benennen Sie die Haupt-HTML eindeutig +- Oder setzen Sie sie nachträglich im Dashboard + +### Passwort vergessen? + +- Im Dashboard können Sie das Passwort jederzeit ändern +- Es gibt keine "Passwort vergessen"-Funktion für Besucher + +### Projekt umbenennen + +- Im Dashboard: Bearbeiten → Namen ändern +- Der Share-Link bleibt gleich! + +## 🆘 Häufige Probleme + +### "404 - Datei nicht gefunden" + +**Ursache:** Asset-Pfade sind falsch +**Lösung:** Prüfen Sie die ZIP-Struktur + +### "Projekt abgelaufen" + +**Ursache:** Ablaufdatum erreicht +**Lösung:** Im Dashboard Ablaufdatum verlängern + +### "Falsches Passwort" + +**Ursache:** Tippfehler oder Cache +**Lösung:** Browser-Cache löschen, erneut versuchen + +### Leere Seite + +**Ursache:** JavaScript-Fehler +**Lösung:** Browser-Konsole prüfen (F12) + +--- + +**Weitere Hilfe:** Kontaktieren Sie den Administrator diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..77e5e97 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,275 @@ +# System-Architektur - PointCab Webexport Server + +Diese Dokumentation beschreibt die technische Architektur des Systems. + +## 🏗️ Übersicht + +``` +┌─────────────────┐ ┌───────────────────┐ ┌─────────────────┐ +│ Browser │◄───►│ Nginx Proxy │◄───►│ NestJS │ +│ (Client) │ │ Manager (443) │ │ (Port 3000) │ +└─────────────────┘ └───────────────────┘ └────────┬────────┘ + │ + ┌─────────────────┴─────────────────┐ + │ │ + ┌───────┴───────┐ ┌───────┴───────┐ + │ PostgreSQL │ │ Filesystem │ + │ (pointcab_db)│ │ (uploads/) │ + └────────────────┘ └────────────────┘ +``` + +## 📂 Verzeichnisstruktur + +``` +/var/www/pointcab_webexport_server/ +├── nodejs_space/ # Haupt-Anwendung +│ ├── src/ +│ │ ├── controllers/ # HTTP-Endpunkte +│ │ │ ├── admin.controller.ts +│ │ │ ├── projects.controller.ts +│ │ │ └── root.controller.ts +│ │ └── services/ # Business-Logik +│ │ ├── admin.service.ts +│ │ ├── projects.service.ts +│ │ ├── upload.service.ts +│ │ └── prisma.service.ts +│ ├── prisma/ +│ │ └── schema.prisma # Datenbank-Schema +│ ├── dist/ # Kompilierter Code +│ ├── uploads/ # Hochgeladene Projekte +│ ├── package.json +│ ├── tsconfig.json +│ └── .env # Konfiguration +└── backups/ # Deployment-Backups +``` + +## 🛠️ Komponenten + +### 1. Controllers + +#### ProjectsController (`projects.controller.ts`) + +Verantwortlich für: +- Projekt-Anzeige (`GET /:shareId/view`) +- Passwort-Authentifizierung (`POST /:shareId/auth`) +- Asset-Serving (`GET /:shareId/*`) + +**Wichtige Funktionen:** +```typescript +// Projekt anzeigen +@Get(':shareId/view') +async viewProject() + +// Assets laden (JS, CSS, Bilder) +@Get(':shareId/*') +async getProjectResource() + +// Passwort-Seite +@Get(':shareId') +async showPasswordPage() +``` + +#### AdminController (`admin.controller.ts`) + +Verantwortlich für: +- Dashboard (`GET /admin/dashboard`) +- Projekt-Verwaltung (CRUD) +- RAR-Entpacken +- Datei-Upload + +### 2. Services + +#### ProjectsService (`projects.service.ts`) + +**Kernfunktionen:** + +1. **Web-Subfolder-Erkennung:** + ```typescript + detectWebSubfolder(projectPath: string): string | null + // Erkennt z.B. "Web_0_web/" Ordner + ``` + +2. **Asset-Pfad-Auflösung:** + ```typescript + resolveAssetPath(projectPath: string, assetPath: string): string + // Löst relative Pfade auf + ``` + +3. **Base-Tag-Injection:** + ```typescript + injectBaseTag(html: string, shareId: string, htmlPath?: string): string + // Fügt für korrekte Asset-Pfade ein + ``` + +#### AdminService (`admin.service.ts`) + +**Kernfunktionen:** + +1. **RAR-Entpacken:** + ```typescript + extractRar(projectId: string, rarPath: string): Promise + // Verwendet spawn() für große Dateien + ``` + +2. **HTML-Erkennung:** + ```typescript + findHtmlFiles(projectPath: string): string[] + // Findet alle HTML-Dateien im Projekt + ``` + +3. **Multi-HTML-Logik:** + ```typescript + processExtractedProject(projectId: string): Promise + // Setzt htmlfilename = null bei mehreren HTMLs + ``` + +#### UploadService (`upload.service.ts`) + +**Kernfunktionen:** +- ZIP/RAR-Upload verarbeiten +- Projekt in Datenbank erstellen +- Multi-HTML-Erkennung + +### 3. Datenbank-Schema + +```prisma +model project { + id String @id @default(uuid()) + name String @unique + shareid String @unique + password String // Klartext (kein Hash!) + htmlfilename String? // NULL bei Multi-HTML + uploaddate DateTime @default(now()) + expirydate DateTime? + createdat DateTime @default(now()) +} +``` + +**Wichtig:** `htmlfilename` ist **nullable** für Multi-HTML-Unterstützung! + +## 🔄 Request-Flow + +### Projekt anzeigen + +``` +1. Browser: GET /abc123/view + ↓ +2. Nginx Proxy: Weiterleitung an :3000 + ↓ +3. ProjectsController.viewProject() + │ + ├─ Projekt aus DB laden + ├─ Passwort-Check (Cookie) + ├─ htmlfilename prüfen + │ ├─ null → HTML-Auswahl-Seite + │ └─ vorhanden → HTML laden + ├─ Web-Subfolder erkennen + ├─ Base-Tag injecten + └─ HTML zurückgeben + ↓ +4. Browser: Rendert HTML + ↓ +5. Browser: Lädt Assets (GET /abc123/js/main.js) + ↓ +6. ProjectsController.getProjectResource() + ├─ Pfad auflösen (mit Subfolder) + └─ Datei zurückgeben +``` + +### RAR entpacken + +``` +1. Admin: POST /admin/projects/:id/extract-rar + ↓ +2. AdminController.extractRar() + ↓ +3. AdminService.extractRar() + ├─ Platzhalter-HTML löschen + ├─ RAR entpacken (spawn) + ├─ HTML-Dateien finden + ├─ Web-Subfolder erkennen + ├─ htmlfilename setzen + │ ├─ 1 HTML → Dateiname + │ └─ >1 HTML → null + └─ DB aktualisieren +``` + +## 🔐 Sicherheit + +### Passwort-Handling + +- Passwörter werden als **Klartext** gespeichert +- Kein bcrypt-Hashing (bewusste Entscheidung für einfache Verwaltung) +- Cookie-basierte Session nach erfolgreicher Authentifizierung + +### Pfad-Sicherheit + +```typescript +// Pfad-Normalisierung verhindert Directory Traversal +const safePath = path.normalize(requestedPath).replace(/^(\.\.\/)+/, ''); +``` + +### Datei-Zugriff + +- Nur Dateien innerhalb des Projekt-Verzeichnisses +- Keine direkten Pfade vom Client +- MIME-Type-Validierung + +## 📊 Technologie-Stack + +| Schicht | Technologie | Version | +|---------|-------------|--------| +| Runtime | Node.js | 18.x LTS | +| Framework | NestJS | 10.x | +| Sprache | TypeScript | 5.x | +| Datenbank | PostgreSQL | 16.x | +| ORM | Prisma | 5.x | +| Process Manager | PM2 | 5.x | +| Reverse Proxy | Nginx Proxy Manager | Latest | +| OS | Ubuntu | 24.04 LTS | + +## 🔧 Konfiguration + +### Umgebungsvariablen (.env) + +```env +PORT=3000 # Server-Port +NODE_ENV=production # Umgebung +DATABASE_URL="postgresql://..." # DB-Verbindung +UPLOAD_DIR=/path/to/uploads # Upload-Verzeichnis +SESSION_SECRET=... # Session-Verschlüsselung +ADMIN_PASSWORD=... # Admin-Zugang +``` + +### PM2 Konfiguration + +```javascript +// ecosystem.config.js +module.exports = { + apps: [{ + name: 'pointcab-server', + script: './dist/main.js', + instances: 1, + autorestart: true, + max_memory_restart: '1G' + }] +}; +``` + +## 📈 Performance + +### Optimierungen + +1. **Spawn statt Exec:** Für RAR-Entpacken (kein Buffer-Limit) +2. **Lazy Loading:** Assets werden on-demand geladen +3. **PM2 Clustering:** Möglich für Skalierung + +### Limits + +- Max Upload: 500 MB (konfigurierbar) +- Max Projekte: Unbegrenzt (Speicherplatz-abhängig) +- Gleichzeitige Verbindungen: Node.js Standard + +--- + +**Siehe auch:** [CHANGELOG.md](CHANGELOG.md) \ No newline at end of file diff --git a/docs/ARCHITECTURE.pdf b/docs/ARCHITECTURE.pdf new file mode 100644 index 0000000..327d516 Binary files /dev/null and b/docs/ARCHITECTURE.pdf differ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..55689b8 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog - PointCab Webexport Server + +Alle wichtigen Änderungen an diesem Projekt werden hier dokumentiert. + +## [1.0.0] - 2026-01-16 + +### 🎉 Erster stabiler Release + +#### Neue Funktionen + +- **Projekt-Upload:** ZIP/RAR-Archive hochladen und automatisch entpacken +- **Manuelle Projekte:** Leere Projekte erstellen und später befüllen +- **Multi-HTML-Unterstützung:** Automatische Erkennung und Auswahl bei mehreren HTML-Dateien +- **Passwort-Schutz:** Optionaler Passwort-Schutz für Projekte +- **Ablaufdatum:** Projekte können ein Ablaufdatum haben +- **Share-Links:** Eindeutige Share-Links für jedes Projekt +- **Admin-Dashboard:** Verwaltung aller Projekte +- **RAR-Entpacken:** Server-seitiges Entpacken von RAR-Archiven + +#### Bugfixes (gegenüber ursprünglicher Version) + +- **404-Fehler bei Assets:** Web-Subfolder-Erkennung für korrekte Asset-Pfade +- **Base-Tag-Injection:** Dynamische Base-Tags basierend auf HTML-Pfad +- **Multi-HTML-Logik:** `htmlfilename = null` bei mehreren HTML-Dateien +- **Passwort-Speicherung:** Klartext statt bcrypt-Hash (für einfache Verwaltung) +- **RAR-Entpacken:** `spawn()` statt `exec()` für große Archive +- **Datenbank-Schema:** `htmlfilename` nullable für Multi-HTML +- **Platzhalter-Löschung:** Automatisches Löschen von Platzhalter-HTML bei RAR-Upload + +--- + +## [Ältere Versionen] + +### [0.9.0] - 2026-01-13 + +#### Bekannte Probleme (behoben in 1.0.0) + +- ❌ 404-Fehler bei Assets in Subfoldern +- ❌ Passwort-Authentifizierung funktionierte nicht (bcrypt-Hash-Problem) +- ❌ Multi-HTML-Projekte zeigten immer nur `index.html` +- ❌ RAR-Entpacken fehlerhaft bei großen Archiven +- ❌ Platzhalter-HTML blieb nach RAR-Upload erhalten + +--- + +## Versionsformat + +Dieses Projekt verwendet [Semantic Versioning](https://semver.org/): + +- **MAJOR:** Inkompatible API-Änderungen +- **MINOR:** Neue Funktionen (abwärtskompatibel) +- **PATCH:** Bugfixes (abwärtskompatibel) + +--- + +## Geplante Funktionen + +### [1.1.0] - Geplant + +- [ ] Statistiken (Aufrufe pro Projekt) +- [ ] E-Mail-Benachrichtigungen bei Ablauf +- [ ] Bulk-Upload (mehrere Projekte gleichzeitig) +- [ ] API für externe Integration + +### [1.2.0] - Geplant + +- [ ] Benutzerverwaltung (mehrere Admins) +- [ ] Projekt-Kategorien +- [ ] Such-Funktion im Dashboard + +--- + +**Dokumentation:** [README.md](../README.md) \ No newline at end of file diff --git a/nodejs_space/.env.example b/nodejs_space/.env.example new file mode 100644 index 0000000..914a489 --- /dev/null +++ b/nodejs_space/.env.example @@ -0,0 +1,19 @@ +# PointCab Webexport Server - Beispiel-Konfiguration +# Kopieren Sie diese Datei nach .env und passen Sie die Werte an + +# Server-Konfiguration +PORT=3000 +NODE_ENV=production + +# Datenbank-Verbindung +# Format: postgresql://BENUTZER:PASSWORT@HOST:PORT/DATENBANK +DATABASE_URL="postgresql://pointcab_user:IhrSicheresPasswort@localhost:5432/pointcab_db" + +# Upload-Verzeichnis +UPLOAD_DIR=/var/www/pointcab_webexport_server/nodejs_space/uploads + +# Session-Secret (mindestens 32 Zeichen, zufällig generieren!) +SESSION_SECRET=aendern-sie-dies-zu-einem-sicheren-zufaelligen-string + +# Admin-Passwort für Dashboard +ADMIN_PASSWORD=IhrAdminPasswort diff --git a/nodejs_space/package.json b/nodejs_space/package.json new file mode 100644 index 0000000..4e084a4 --- /dev/null +++ b/nodejs_space/package.json @@ -0,0 +1,106 @@ +{ + "name": "nodejs_space", + "version": "0.0.1", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.3", + "@prisma/client": "6.7.0", + "@types/bcrypt": "^6.0.0", + "@types/bcryptjs": "^3.0.0", + "@types/cookie-parser": "^1.4.10", + "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.0.0", + "@types/passport-jwt": "^4.0.1", + "@types/uuid": "^11.0.0", + "adm-zip": "^0.5.16", + "bcrypt": "^6.0.0", + "bcryptjs": "^3.0.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", + "compression": "^1.8.1", + "cookie-parser": "^1.4.7", + "fs-extra": "^11.3.3", + "jsonwebtoken": "^9.0.3", + "multer": "^2.0.2", + "node-unrar-js": "^2.0.2", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/adm-zip": "^0", + "@types/compression": "^1", + "@types/express": "^5.0.0", + "@types/fs-extra": "^11.0.4", + "@types/jest": "^30.0.0", + "@types/node": "22.0.0", + "@types/passport": "^0", + "@types/supertest": "^6.0.2", + "@typescript-eslint/eslint-plugin": "8.18.0", + "@typescript-eslint/parser": "8.18.0", + "eslint": "9.33.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "5.1.3", + "globals": "^16.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "prisma": "6.7.0", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "5.6.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + }, + "engines": { + "node": ">=18.18.0" + }, + "packageManager": "yarn@4.12.0" +} diff --git a/nodejs_space/prisma/schema.prisma b/nodejs_space/prisma/schema.prisma new file mode 100644 index 0000000..cf19919 --- /dev/null +++ b/nodejs_space/prisma/schema.prisma @@ -0,0 +1,22 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model project { + id String @id @default(uuid()) + name String @unique + shareid String @unique // Zufällige ID für sichere Links (z.B. "abc7x9k2") + password String + htmlfilename String? // GEÄNDERT: Optional, damit null gespeichert werden kann (für Multi-HTML-Projekte) + uploaddate DateTime @default(now()) + expirydate DateTime? // Optionales Ablaufdatum + createdat DateTime @default(now()) +} diff --git a/nodejs_space/src/controllers/admin.controller.ts b/nodejs_space/src/controllers/admin.controller.ts new file mode 100644 index 0000000..3ec2b7e --- /dev/null +++ b/nodejs_space/src/controllers/admin.controller.ts @@ -0,0 +1,174 @@ +import { + Controller, + Post, + Get, + Delete, + Put, + Body, + Param, + UseGuards, + UseInterceptors, + UploadedFiles, + Logger, +} from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes, ApiBody } from '@nestjs/swagger'; +import { diskStorage } from 'multer'; +import { v4 as uuidv4 } from 'uuid'; +import * as path from 'path'; +import { AdminService } from '../services/admin.service'; +import { UploadService } from '../services/upload.service'; +import { AdminLoginDto } from '../dto/admin-login.dto'; +import { ChangePasswordDto } from '../dto/change-password.dto'; +import { RenameProjectDto } from '../dto/rename-project.dto'; +import { SetExpiryDto } from '../dto/set-expiry.dto'; +import { AdminJwtAuthGuard } from '../guards/admin-jwt.guard'; + +@ApiTags('Admin') +@Controller('api/admin') +export class AdminController { + private readonly logger = new Logger(AdminController.name); + + constructor( + private adminService: AdminService, + private uploadService: UploadService, + ) {} + + @Post('login') + @ApiOperation({ summary: 'Admin login' }) + @ApiResponse({ status: 200, description: 'Login successful, returns JWT token' }) + @ApiResponse({ status: 401, description: 'Invalid credentials' }) + async login(@Body() loginDto: AdminLoginDto) { + return this.adminService.login(loginDto.username, loginDto.password); + } + + @Post('upload') + @UseGuards(AdminJwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Upload project archive (ZIP/RAR, including split archives)' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + files: { + type: 'array', + items: { + type: 'string', + format: 'binary', + }, + }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Project uploaded and extracted successfully' }) + @ApiResponse({ status: 400, description: 'Bad request' }) + @UseInterceptors( + FilesInterceptor('files', 50, { + storage: diskStorage({ + destination: '/home/ubuntu/pointcab_webexport_server/uploads/temp', + filename: (req, file, cb) => { + const uniqueName = `${uuidv4()}-${file.originalname}`; + cb(null, uniqueName); + }, + }), + limits: { + fileSize: 5 * 1024 * 1024 * 1024, // 5GB per file + }, + }), + ) + async uploadProject(@UploadedFiles() files: Express.Multer.File[]) { + this.logger.log(`Upload request received with ${files.length} file(s)`); + return this.uploadService.handleFileUpload(files); + } + + @Get('projects') + @UseGuards(AdminJwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get all projects' }) + @ApiResponse({ status: 200, description: 'List of all projects' }) + async getAllProjects() { + return this.adminService.getAllProjects(); + } + + @Delete('projects/:id') + @UseGuards(AdminJwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete a project' }) + @ApiResponse({ status: 200, description: 'Project deleted successfully' }) + @ApiResponse({ status: 404, description: 'Project not found' }) + async deleteProject(@Param('id') id: string) { + await this.uploadService.deleteProject(id); + return { message: 'Project deleted successfully' }; + } + + @Put('projects/:id/password') + @UseGuards(AdminJwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Change project password' }) + @ApiResponse({ status: 200, description: 'Password changed successfully' }) + @ApiResponse({ status: 404, description: 'Project not found' }) + async changeProjectPassword( + @Param('id') id: string, + @Body() changePasswordDto: ChangePasswordDto, + ) { + return this.adminService.changeProjectPassword(id, changePasswordDto.newPassword); + } + + @Put('projects/:id/rename') + @UseGuards(AdminJwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Rename a project' }) + @ApiResponse({ status: 200, description: 'Project renamed successfully' }) + @ApiResponse({ status: 404, description: 'Project not found' }) + async renameProject( + @Param('id') id: string, + @Body() renameProjectDto: RenameProjectDto, + ) { + return this.adminService.renameProject(id, renameProjectDto.newName); + } + + @Put('projects/:id/expiry') + @UseGuards(AdminJwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Set or remove project expiry date' }) + @ApiResponse({ status: 200, description: 'Expiry date updated successfully' }) + @ApiResponse({ status: 404, description: 'Project not found' }) + async setExpiryDate( + @Param('id') id: string, + @Body() setExpiryDto: SetExpiryDto, + ) { + return this.adminService.setExpiryDate(id, setExpiryDto.expiryDate ?? null); + } + + @Post('projects/create-manual') + @UseGuards(AdminJwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create an empty manual project for FTP upload' }) + @ApiResponse({ status: 201, description: 'Manual project created successfully' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + projectName: { + type: 'string', + description: 'Optional project name', + }, + }, + }, + }) + async createManualProject(@Body('projectName') projectName?: string) { + return this.adminService.createManualProject(projectName); + } + + @Post('projects/:id/extract-rar') + @UseGuards(AdminJwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Extract RAR files in a project' }) + @ApiResponse({ status: 200, description: 'RAR files extracted successfully' }) + @ApiResponse({ status: 404, description: 'Project not found' }) + @ApiResponse({ status: 400, description: 'No RAR files found or extraction failed' }) + async extractRarFiles(@Param('id') id: string) { + return this.adminService.extractRarFiles(id); + } +} diff --git a/nodejs_space/src/controllers/projects.controller.ts b/nodejs_space/src/controllers/projects.controller.ts new file mode 100644 index 0000000..adf92a4 --- /dev/null +++ b/nodejs_space/src/controllers/projects.controller.ts @@ -0,0 +1,385 @@ +import { Controller, Get, Post, Body, Param, Res, UseGuards, HttpCode, Logger, Query, Req } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { ProjectsService } from '../services/projects.service'; +import { ProjectAuthDto } from '../dto/project-auth.dto'; +import { ProjectCookieGuard } from '../guards/project-cookie.guard'; + +@ApiTags('Projects') +@Controller() +export class ProjectsController { + private readonly logger = new Logger(ProjectsController.name); + + constructor(private readonly projectsService: ProjectsService) {} + + @Get(':shareId') + @ApiOperation({ summary: 'Project password page' }) + @ApiParam({ name: 'shareId', description: 'Project share ID' }) + @ApiExcludeEndpoint() + async getProjectPasswordPage(@Param('shareId') shareId: string, @Res() res: Response) { + try { + await this.projectsService.validateShareId(shareId); + + const html = ` + + + + + PointCab Webexport - Passwort erforderlich + + + +
+

🔒 PointCab Webexport

+

Dieses Projekt ist passwortgeschützt.

+
+
+ + +
+
+ + +`; + + return res.send(html); + } catch (error) { + return res.status(404).send('

404 - Project not found

'); + } + } + + @Post(':shareId/auth') + @HttpCode(200) + @ApiOperation({ summary: 'Authenticate with project password' }) + @ApiParam({ name: 'shareId', description: 'Project share ID' }) + @ApiBody({ type: ProjectAuthDto }) + @ApiResponse({ status: 200, description: 'Authentication successful' }) + @ApiResponse({ status: 401, description: 'Invalid password' }) + async authenticate( + @Param('shareId') shareId: string, + @Body() authDto: ProjectAuthDto, + @Res() res: Response, + ) { + try { + const { token } = await this.projectsService.authenticateProject(shareId, authDto.password); + + res.cookie(`project_token_${shareId}`, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 24 * 60 * 60 * 1000, + sameSite: 'strict', + }); + + return res.json({ success: true, message: 'Authentication successful' }); + } catch (error) { + return res.status(401).json({ message: error.message }); + } + } + + @Get(':shareId/select-html') + @UseGuards(ProjectCookieGuard) + @ApiExcludeEndpoint() + async getHtmlSelection(@Param('shareId') shareId: string, @Res() res: Response) { + try { + const project = await this.projectsService.getProjectByShareId(shareId); + const htmlFiles = await this.projectsService.getProjectHtmlFiles(project.name); + + const html = ` + + + + + HTML-Datei auswählen + + + +
+

📄 Mehrere HTML-Dateien gefunden

+

Bitte wählen Sie die Datei aus, die Sie öffnen möchten:

+
+

ℹ️ Diese Auswahl wird bei jedem Besuch angezeigt - Sie können frei zwischen den Etagen/Dokumentationen wechseln.

+
+
+ ${htmlFiles.map(file => ` +
+
+ 📄 + ${file} +
+ Öffnen +
+ `).join('')} +
+
+ +`; + + return res.send(html); + } catch (error) { + return res.status(404).send('

404 - Project not found

'); + } + } + + /** + * GEÄNDERT: HTML-Datei wird per Query-Parameter übergeben, aber NICHT gespeichert. + * Bei jedem Aufruf ohne Parameter wird die Auswahlseite angezeigt (bei mehreren HTMLs). + * + * FIX: Der Base-Tag wird jetzt mit dem HTML-Dateipfad injiziert, damit relative + * Pfade in Unterordnern korrekt funktionieren! + */ + @Get(':shareId/view') + @UseGuards(ProjectCookieGuard) + @ApiExcludeEndpoint() + async viewProject( + @Param('shareId') shareId: string, + @Query('html') htmlFilename: string, + @Res() res: Response, + ) { + try { + // GEÄNDERT: HTML-Datei wird NICHT mehr gespeichert! + // Stattdessen wird sie nur für diesen Request verwendet. + const { path: filePath, isHtml, htmlRelativePath } = await this.projectsService.getProjectFile(shareId, '', htmlFilename); + + if (isHtml) { + // Lese HTML-Datei und füge Tag ein + const fs = await import('fs-extra'); + let htmlContent = await fs.readFile(filePath, 'utf-8'); + + // FIX: Übergebe den HTML-Dateipfad für korrekten Base-Tag! + htmlContent = this.projectsService.injectBaseTag(htmlContent, shareId, htmlRelativePath); + + this.logger.log(`📄 Serving HTML: ${htmlRelativePath} with base tag for: /${shareId}/${htmlRelativePath ? require('path').dirname(htmlRelativePath) + '/' : ''}`); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.send(htmlContent); + } + + return res.sendFile(filePath); + } catch (error) { + if (error.message === 'MULTIPLE_HTML_FILES') { + return res.redirect(`/${shareId}/select-html`); + } + + this.logger.error(`Error viewing project: ${error.message}`); + return res.status(404).json({ message: 'File not found', error: 'Not Found', statusCode: 404 }); + } + } + + /** + * FIX: Bei HTML-Dateien in Unterordnern wird der Base-Tag korrekt gesetzt! + */ + @Get(':shareId/*') + @UseGuards(ProjectCookieGuard) + @ApiExcludeEndpoint() + async getProjectResource( + @Param('shareId') shareId: string, + @Req() req: Request, + @Res() res: Response, + ) { + try { + // Extract the file path after the shareId + // URL format: /:shareId/path/to/file.js + const fullUrl = req.url; + const pathAfterShareId = fullUrl.split(`/${shareId}/`)[1] || ''; + const filePath = decodeURIComponent(pathAfterShareId.split('?')[0]); // Remove query params + + this.logger.log(`📂 Requesting file: ${shareId}/${filePath}`); + + const { path: fullPath, isHtml, htmlRelativePath } = await this.projectsService.getProjectFile(shareId, filePath); + + const ext = filePath.toLowerCase().split('.').pop(); + const mimeTypes: Record = { + 'html': 'text/html; charset=utf-8', + 'css': 'text/css; charset=utf-8', + 'js': 'application/javascript; charset=utf-8', + 'json': 'application/json; charset=utf-8', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'svg': 'image/svg+xml', + 'ico': 'image/x-icon', + 'woff': 'font/woff', + 'woff2': 'font/woff2', + 'ttf': 'font/ttf', + 'eot': 'application/vnd.ms-fontobject', + 'xml': 'application/xml; charset=utf-8', + 'txt': 'text/plain; charset=utf-8', + }; + + if (ext && mimeTypes[ext]) { + res.setHeader('Content-Type', mimeTypes[ext]); + } + + // HTML-Dateien mit Tag versehen + // FIX: Übergebe den HTML-Dateipfad für korrekten Base-Tag! + if (isHtml) { + const fs = await import('fs-extra'); + let htmlContent = await fs.readFile(fullPath, 'utf-8'); + + // Der htmlRelativePath ist der gleiche wie filePath für Ressourcen + htmlContent = this.projectsService.injectBaseTag(htmlContent, shareId, filePath); + + this.logger.log(`📄 Serving sub-HTML: ${filePath} with base tag`); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + return res.send(htmlContent); + } + + if (['css', 'js', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'woff', 'woff2', 'ttf', 'eot'].includes(ext || '')) { + res.setHeader('Cache-Control', 'public, max-age=31536000'); + } else { + res.setHeader('Cache-Control', 'public, max-age=3600'); + } + + return res.sendFile(fullPath); + } catch (error) { + const fullUrl = req.url; + const pathAfterShareId = fullUrl.split(`/${shareId}/`)[1] || ''; + const filePath = decodeURIComponent(pathAfterShareId.split('?')[0]); + + this.logger.error(`❌ File not found: ${shareId}/${filePath} (${error.message})`); + return res.status(404).json({ message: `File not found: ${filePath}`, error: 'Not Found', statusCode: 404 }); + } + } +} diff --git a/nodejs_space/src/controllers/root.controller.ts b/nodejs_space/src/controllers/root.controller.ts new file mode 100644 index 0000000..7f0053a --- /dev/null +++ b/nodejs_space/src/controllers/root.controller.ts @@ -0,0 +1,870 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { Response } from 'express'; +import { AdminService } from '../services/admin.service'; + +@ApiExcludeController() +@Controller() +export class RootController { + constructor(private readonly adminService: AdminService) {} + + @Get() + getLoginPage(@Res() res: Response) { + const html = ` + + + + + PointCab Webexport Server - Admin Login + + + + + + + + +`; + res.send(html); + } + + @Get('admin/dashboard') + getDashboard(@Res() res: Response) { + res.send(this.getDashboardHTML()); + } + + private getDashboardHTML(): string { + return ` + + + + + PointCab Admin Dashboard + + + + +
+ + +
+ +
+
+ + + +
+ + +
+
+

Projekt hochladen

+
+
📦
+

Datei hier ablegen oder klicken zum Auswählen

+

ZIP oder RAR Archive (auch gesplittete Archive)

+
+ +
+
0%
+
+
+ +
+ +

+ Erstellt ein leeres Projekt, in das Sie Dateien per FTP hochladen können +

+
+
+
+ + +
+
+

Alle Projekte

+
+
Projekte werden geladen...
+
+
+
+ + + +
+ + + +`; + } +} diff --git a/nodejs_space/src/services/admin.service.ts b/nodejs_space/src/services/admin.service.ts new file mode 100644 index 0000000..7e8ce98 --- /dev/null +++ b/nodejs_space/src/services/admin.service.ts @@ -0,0 +1,402 @@ +import { Injectable, UnauthorizedException, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from './prisma.service'; +import { AdminJwtPayload } from '../interfaces/jwt-payload.interface'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { spawn } from 'child_process'; + +@Injectable() +export class AdminService { + private readonly logger = new Logger(AdminService.name); + private readonly uploadDir: string; + + constructor( + private prisma: PrismaService, + private jwtService: JwtService, + private configService: ConfigService, + ) { + // Read upload path from .env with fallback + this.uploadDir = this.configService.get('UPLOAD_DIR') || '/home/ubuntu/pointcab_webexport_server/uploads/projects'; + this.logger.log(`📁 AdminService using upload directory: ${this.uploadDir}`); + } + + async login(username: string, password: string): Promise<{ token: string }> { + const adminUsername = this.configService.get('ADMIN_USERNAME'); + const adminPassword = this.configService.get('ADMIN_PASSWORD'); + + if (username !== adminUsername || password !== adminPassword) { + this.logger.warn(`Failed login attempt for username: ${username}`); + throw new UnauthorizedException('Invalid credentials'); + } + + const payload: AdminJwtPayload = { + sub: 'admin-user', + username: username, + type: 'admin', + }; + + this.logger.log(`Admin '${username}' logged in successfully`); + + return { + token: this.jwtService.sign(payload), + }; + } + + async getAllProjects() { + const projects = await this.prisma.project.findMany({ + orderBy: { uploaddate: 'desc' }, + }); + + return projects.map(project => ({ + id: project.id, + name: project.name, + shareid: project.shareid, + password: project.password, + htmlFileName: project.htmlfilename, + uploadDate: project.uploaddate, + uploaddate: project.uploaddate, + expiryDate: project.expirydate, + link: `/${project.shareid}`, + })); + } + + async renameProject(projectId: string, newName: string) { + const project = await this.prisma.project.update({ + where: { id: projectId }, + data: { name: newName }, + }); + + this.logger.log(`Project renamed to '${newName}'`); + + return { + id: project.id, + name: project.name, + message: 'Project renamed successfully', + }; + } + + async setExpiryDate(projectId: string, expiryDate: string | null) { + const project = await this.prisma.project.update({ + where: { id: projectId }, + data: { expirydate: expiryDate ? new Date(expiryDate) : null }, + }); + + this.logger.log(`Expiry date set for project '${project.name}': ${expiryDate || 'removed'}`); + + return { + id: project.id, + name: project.name, + expiryDate: project.expirydate, + message: expiryDate ? 'Expiry date set successfully' : 'Expiry date removed', + }; + } + + async changeProjectPassword(projectId: string, newPassword: string) { + const project = await this.prisma.project.update({ + where: { id: projectId }, + data: { password: newPassword }, + }); + + this.logger.log(`Password changed for project '${project.name}'`); + + return { + id: project.id, + name: project.name, + password: project.password, + message: 'Password updated successfully', + }; + } + + /** + * Erstellt ein leeres manuelles Projekt für FTP-Upload + */ + async createManualProject(projectName?: string) { + // Generiere Zufalls-Share-ID und Passwort + const shareId = this.generateRandomString(8); + const password = this.generateRandomString(12); + const name = projectName || `Manual_Project_${Date.now()}`; + + // Erstelle Projektverzeichnis + const projectDir = path.join(this.uploadDir, name); + await fs.ensureDir(projectDir); + + // Erstelle Platzhalter-HTML + const placeholderHtml = ` + + + + + ${name} + + +

Projekt: ${name}

+

Dieses Projekt wurde manuell angelegt. Bitte laden Sie die Dateien per FTP hoch.

+

Pfad: ${projectDir}

+ +`; + + const htmlFileName = 'index.html'; + await fs.writeFile(path.join(projectDir, htmlFileName), placeholderHtml); + + // Speichere in Datenbank - htmlfilename wird auf null gesetzt + // damit nach dem Entpacken die richtige HTML-Datei erkannt wird + const project = await this.prisma.project.create({ + data: { + name, + shareid: shareId, + password, + htmlfilename: htmlFileName, + uploaddate: new Date(), + }, + }); + + this.logger.log(`Manual project created: ${name} (${shareId})`); + + return { + id: project.id, + name: project.name, + shareid: project.shareid, + password: project.password, + projectPath: projectDir, + link: `/${shareId}`, + message: 'Manuelles Projekt erstellt. Sie können jetzt Dateien per FTP hochladen.', + }; + } + + /** + * Entfernt die Platzhalter-HTML-Datei (für manuell erstellte Projekte) + */ + private async removePlaceholderHtml(projectDir: string): Promise { + const indexPath = path.join(projectDir, 'index.html'); + + if (await fs.pathExists(indexPath)) { + try { + const content = await fs.readFile(indexPath, 'utf-8'); + + // Prüfen ob es ein Platzhalter ist (enthält "manuell angelegt") + if (content.includes('manuell angelegt') || content.includes('Dieses Projekt wurde manuell angelegt')) { + await fs.remove(indexPath); + this.logger.log(`🗑️ Removed placeholder index.html`); + } + } catch (error) { + this.logger.warn(`Could not check/remove placeholder: ${error.message}`); + } + } + } + + /** + * Führt unrar-Befehl mit spawn() aus (für große Dateien) + */ + private async runUnrar(projectDir: string, rarFile: string): Promise<{ success: boolean; output: string }> { + return new Promise((resolve) => { + const unrar = spawn('unrar', ['x', '-o+', rarFile], { + cwd: projectDir, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + unrar.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + unrar.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + unrar.on('close', (code) => { + if (code === 0) { + resolve({ success: true, output: stdout }); + } else { + resolve({ success: false, output: stderr || stdout }); + } + }); + + unrar.on('error', (error) => { + resolve({ success: false, output: error.message }); + }); + + // Timeout nach 30 Minuten (für sehr große Archive) + setTimeout(() => { + unrar.kill(); + resolve({ success: false, output: 'Timeout: Extraction took too long (> 30 minutes)' }); + }, 30 * 60 * 1000); + }); + } + + /** + * Löscht alle RAR-Dateien im Projektverzeichnis + */ + private async deleteRarFiles(projectDir: string): Promise { + const files = await fs.readdir(projectDir); + const rarFiles = files.filter(f => + f.toLowerCase().endsWith('.rar') || + /\.(part\d+|r\d+)$/i.test(f) + ); + + const deletedFiles: string[] = []; + + for (const rarFile of rarFiles) { + try { + const rarPath = path.join(projectDir, rarFile); + await fs.remove(rarPath); + deletedFiles.push(rarFile); + } catch (error) { + this.logger.warn(`⚠️ Could not delete ${rarFile}: ${error.message}`); + } + } + + if (deletedFiles.length > 0) { + this.logger.log(`🗑️ Deleted RAR files: ${deletedFiles.join(', ')}`); + } + + return deletedFiles; + } + + /** + * Entpackt RAR-Dateien in einem Projekt + * + * ÄNDERUNGEN: + * - Verwendet spawn() statt exec() für unbegrenzten Buffer + * - Löscht RAR-Dateien nach erfolgreichem Entpacken + * - Bessere Fehlerbehandlung und Logging + */ + async extractRarFiles(projectId: string) { + this.logger.log(`📦 [RAR-EXTRACT] Starting extraction for project ID: ${projectId}`); + + const project = await this.prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + throw new NotFoundException('Projekt nicht gefunden'); + } + + const projectDir = path.join(this.uploadDir, project.name); + this.logger.log(`📂 [RAR-EXTRACT] Project directory: ${projectDir}`); + + // Prüfe ob Verzeichnis existiert + if (!await fs.pathExists(projectDir)) { + throw new NotFoundException(`Projektverzeichnis nicht gefunden: ${projectDir}`); + } + + // Entferne Platzhalter-HTML falls vorhanden + await this.removePlaceholderHtml(projectDir); + + // Suche nach RAR-Dateien + const files = await fs.readdir(projectDir); + const rarFiles = files.filter(f => { + const lower = f.toLowerCase(); + // Haupt-RAR oder erste Part-Datei + return lower.endsWith('.rar') && !lower.match(/\.part0*[2-9]\d*\.rar$/); + }); + + this.logger.log(`📋 [RAR-EXTRACT] Found RAR files to extract: ${rarFiles.join(', ') || 'none'}`); + + if (rarFiles.length === 0) { + throw new BadRequestException('Keine RAR-Dateien gefunden'); + } + + // Prüfe ob unrar installiert ist + try { + const whichUnrar = spawn('which', ['unrar']); + await new Promise((resolve, reject) => { + whichUnrar.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error('unrar not found')); + }); + }); + } catch { + throw new BadRequestException('unrar ist nicht installiert. Bitte installieren Sie: apt-get install unrar'); + } + + const results: Array<{ file: string; status: string; error?: string }> = []; + let allSuccessful = true; + + for (const rarFile of rarFiles) { + this.logger.log(`📦 [RAR-EXTRACT] Extracting: ${rarFile}`); + + const result = await this.runUnrar(projectDir, rarFile); + + if (result.success) { + this.logger.log(`✅ [RAR-EXTRACT] Successfully extracted: ${rarFile}`); + results.push({ file: rarFile, status: 'success' }); + } else { + this.logger.error(`❌ [RAR-EXTRACT] Failed to extract ${rarFile}: ${result.output}`); + results.push({ file: rarFile, status: 'error', error: result.output }); + allSuccessful = false; + } + } + + // Nach erfolgreichem Entpacken: RAR-Dateien löschen + let deletedRarFiles: string[] = []; + if (allSuccessful) { + this.logger.log(`🗑️ [RAR-EXTRACT] Deleting RAR files after successful extraction...`); + deletedRarFiles = await this.deleteRarFiles(projectDir); + } + + // Suche nach HTML-Dateien und aktualisiere Datenbank + const allFilesAfterExtract = await fs.readdir(projectDir); + const htmlFiles = allFilesAfterExtract.filter(f => f.toLowerCase().endsWith('.html')); + + this.logger.log(`📋 [MULTI-HTML-LOGIC] Found ${htmlFiles.length} HTML file(s): ${htmlFiles.join(', ')}`); + + // Bestimme htmlfilename basierend auf Anzahl der HTML-Dateien + let newHtmlFilename: string | null = null; + + if (htmlFiles.length === 1) { + // Nur eine HTML-Datei - diese verwenden + newHtmlFilename = htmlFiles[0]; + this.logger.log(`📄 [MULTI-HTML-LOGIC] Single HTML file found, setting: ${newHtmlFilename}`); + } else if (htmlFiles.length > 1) { + // MEHRERE HTML-Dateien - htmlfilename auf null setzen für Auswahlseite + newHtmlFilename = null; + this.logger.log(`📄 [MULTI-HTML-LOGIC] Multiple HTML files found (${htmlFiles.length}), setting htmlfilename to NULL for selection page`); + } + + // Datenbank aktualisieren + await this.prisma.project.update({ + where: { id: projectId }, + data: { htmlfilename: newHtmlFilename }, + }); + + this.logger.log(`✅ [RAR-EXTRACT] Updated database: htmlfilename = ${newHtmlFilename ?? 'NULL'}`); + + const successCount = results.filter(r => r.status === 'success').length; + const failCount = results.filter(r => r.status === 'error').length; + + // Erstelle Erfolgs-Response + const response = { + success: allSuccessful, + projectName: project.name, + extractedFiles: successCount, + failedFiles: failCount, + deletedRarFiles: deletedRarFiles, + htmlFilesFound: htmlFiles, + htmlFilename: newHtmlFilename, + details: results, + message: allSuccessful + ? `✅ ${successCount} Archiv(e) erfolgreich entpackt. ${deletedRarFiles.length} RAR-Datei(en) gelöscht.` + : `⚠️ ${successCount} erfolgreich, ${failCount} fehlgeschlagen`, + }; + + this.logger.log(`📋 [RAR-EXTRACT] Final response: ${JSON.stringify(response)}`); + + return response; + } + + /** + * Generiert einen zufälligen String + */ + private generateRandomString(length: number): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + } +} diff --git a/nodejs_space/src/services/prisma.service.ts b/nodejs_space/src/services/prisma.service.ts new file mode 100644 index 0000000..bb6565f --- /dev/null +++ b/nodejs_space/src/services/prisma.service.ts @@ -0,0 +1,13 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/nodejs_space/src/services/projects.service.ts b/nodejs_space/src/services/projects.service.ts new file mode 100644 index 0000000..e18d122 --- /dev/null +++ b/nodejs_space/src/services/projects.service.ts @@ -0,0 +1,300 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as jwt from 'jsonwebtoken'; + +@Injectable() +export class ProjectsService { + private readonly logger = new Logger(ProjectsService.name); + private readonly uploadDir: string; + + constructor( + private prisma: PrismaService, + private configService: ConfigService, + ) { + this.uploadDir = this.configService.get('UPLOAD_DIR') || '/var/www/pointcab_webexport_server/uploads/projects'; + this.logger.log(`📁 ProjectsService using upload directory: ${this.uploadDir}`); + } + + async authenticateProject(shareId: string, password: string): Promise<{ token: string; project: any }> { + const project = await this.prisma.project.findUnique({ + where: { shareid: shareId }, + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + // Check expiry + if (project.expirydate && new Date(project.expirydate) < new Date()) { + throw new NotFoundException('Project has expired'); + } + + // Plain text password comparison + if (password !== project.password) { + throw new NotFoundException('Invalid password'); + } + + // Generate JWT token + const jwtSecret = this.configService.get('PROJECT_JWT_SECRET') || 'fallback-secret-key'; + const token = jwt.sign( + { + sub: project.id, + projectName: project.name, + shareId: project.shareid, + type: 'project' + }, + jwtSecret, + { expiresIn: '24h' } + ); + + return { token, project }; + } + + async getProjectByShareId(shareId: string) { + const project = await this.prisma.project.findUnique({ + where: { shareid: shareId }, + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + // Check expiry + if (project.expirydate && new Date(project.expirydate) < new Date()) { + throw new NotFoundException('Project has expired'); + } + + return project; + } + + async getProjectHtmlFiles(projectName: string): Promise { + const projectPath = path.join(this.uploadDir, projectName); + + if (!fs.existsSync(projectPath)) { + this.logger.error(`Project directory not found: ${projectPath}`); + throw new NotFoundException('Project directory not found'); + } + + const files = fs.readdirSync(projectPath); + const htmlFiles = files.filter(file => file.toLowerCase().endsWith('.html')); + + this.logger.log(`Found ${htmlFiles.length} HTML files in ${projectName}: ${htmlFiles.join(', ')}`); + + return htmlFiles; + } + + async getProjectFileContent(projectName: string, filename: string): Promise<{ content: Buffer; mimeType: string }> { + const projectPath = path.join(this.uploadDir, projectName); + const filePath = path.join(projectPath, filename); + + // Security check: prevent directory traversal + const normalizedProjectPath = path.normalize(projectPath); + const normalizedFilePath = path.normalize(filePath); + + if (!normalizedFilePath.startsWith(normalizedProjectPath)) { + this.logger.error(`Security violation: Attempted path traversal to ${filePath}`); + throw new NotFoundException('Invalid file path'); + } + + if (!fs.existsSync(filePath)) { + this.logger.error(`File not found: ${filePath}`); + throw new NotFoundException(`File not found: ${filename}`); + } + + const content = fs.readFileSync(filePath); + const mimeType = this.getMimeType(filename); + + return { content, mimeType }; + } + + /** + * Injects a tag into HTML content to fix relative paths. + * + * FIX: Der Base-Tag berücksichtigt jetzt das Verzeichnis der HTML-Datei! + * - Für HTML im Root (z.B. Web_0.html): + * - Für HTML in Unterordner (z.B. Web_0_web/pano.html): + * + * @param htmlContent Der HTML-Inhalt + * @param shareId Die Share-ID des Projekts + * @param htmlFilePath Der relative Pfad der HTML-Datei (z.B. "Web_0.html" oder "Web_0_web/pano.html") + */ + injectBaseTag(htmlContent: string, shareId: string, htmlFilePath?: string): string { + // Ermittle das Basisverzeichnis basierend auf dem HTML-Dateipfad + let basePath = `/${shareId}/`; + + if (htmlFilePath) { + // Extrahiere das Verzeichnis aus dem HTML-Pfad + const htmlDir = path.dirname(htmlFilePath); + + // Wenn die HTML-Datei in einem Unterordner ist, füge diesen zum Base-Pfad hinzu + if (htmlDir && htmlDir !== '.' && htmlDir !== '') { + basePath = `/${shareId}/${htmlDir}/`; + this.logger.log(`🔗 [BASE-TAG] HTML in subfolder: ${htmlFilePath} -> Base: ${basePath}`); + } else { + this.logger.log(`🔗 [BASE-TAG] HTML in root: ${htmlFilePath} -> Base: ${basePath}`); + } + } + + const baseTag = ``; + + // Entferne existierende Tags + let cleanedContent = htmlContent.replace(/]*>/gi, ''); + + // Try to inject after + if (cleanedContent.includes('')) { + return cleanedContent.replace('', `\n ${baseTag}`); + } + + // Try to inject after ]*>/i); + if (headMatch) { + return cleanedContent.replace(headMatch[0], `${headMatch[0]}\n ${baseTag}`); + } + + // Fallback: inject after + if (cleanedContent.includes('')) { + return cleanedContent.replace('', `\n\n ${baseTag}\n`); + } + + // Fallback: prepend to document + return `\n\n\n ${baseTag}\n\n\n${cleanedContent}\n\n`; + } + + async getProjectHtmlContent(projectName: string, htmlFile: string, shareId: string): Promise { + const projectPath = path.join(this.uploadDir, projectName); + const filePath = path.join(projectPath, htmlFile); + + // Security check + const normalizedProjectPath = path.normalize(projectPath); + const normalizedFilePath = path.normalize(filePath); + + if (!normalizedFilePath.startsWith(normalizedProjectPath)) { + this.logger.error(`Security violation: Attempted path traversal to ${filePath}`); + throw new NotFoundException('Invalid file path'); + } + + if (!fs.existsSync(filePath)) { + this.logger.error(`HTML file not found: ${filePath}`); + throw new NotFoundException(`HTML file not found: ${htmlFile}`); + } + + let htmlContent = fs.readFileSync(filePath, 'utf-8'); + + // Inject tag to fix relative paths - MIT HTML-Dateipfad! + htmlContent = this.injectBaseTag(htmlContent, shareId, htmlFile); + this.logger.log(`✅ Injected tag for shareId: ${shareId}, htmlFile: ${htmlFile}`); + + return htmlContent; + } + + /** + * Get project file by shareId and relative path + * Used by the catch-all route /:shareId/* + * + * GEÄNDERT: Bei mehreren HTML-Dateien wird IMMER die Auswahlseite angezeigt. + * Die Auswahl wird NICHT in der Datenbank gespeichert. + * + * @returns path: absoluter Pfad zur Datei, isHtml: ob es eine HTML-Datei ist, htmlRelativePath: relativer Pfad zur HTML-Datei + */ + async getProjectFile(shareId: string, relativePath: string, requestedHtml?: string): Promise<{ path: string; isHtml: boolean; htmlRelativePath?: string }> { + const project = await this.getProjectByShareId(shareId); + const projectPath = path.join(this.uploadDir, project.name); + + // If no relative path is provided, determine which HTML file to use + let targetPath = relativePath; + if (!relativePath || relativePath === '') { + // GEÄNDERT: Zuerst HTML-Dateien scannen + const htmlFiles = await this.getProjectHtmlFiles(project.name); + + if (htmlFiles.length === 0) { + this.logger.error(`No HTML files found in project ${project.name}`); + throw new NotFoundException('No HTML files found in project'); + } else if (htmlFiles.length === 1) { + // Nur eine HTML-Datei - automatisch verwenden + targetPath = htmlFiles[0]; + this.logger.log(`📄 Auto-selected single HTML file: ${targetPath}`); + } else { + // MEHRERE HTML-Dateien gefunden + if (requestedHtml && htmlFiles.includes(requestedHtml)) { + // Nutzer hat eine spezifische HTML-Datei gewählt (via Query-Parameter) + // WICHTIG: Wird NICHT in der Datenbank gespeichert! + targetPath = requestedHtml; + this.logger.log(`📄 Using requested HTML file (not saved): ${targetPath}`); + } else { + // Keine Auswahl getroffen - zur Auswahlseite weiterleiten + this.logger.log(`📋 Multiple HTML files found (${htmlFiles.length}), redirecting to selection page`); + throw new Error('MULTIPLE_HTML_FILES'); + } + } + } + + const filePath = path.join(projectPath, targetPath); + + // Security check: prevent directory traversal + const normalizedProjectPath = path.normalize(projectPath); + const normalizedFilePath = path.normalize(filePath); + + if (!normalizedFilePath.startsWith(normalizedProjectPath)) { + this.logger.error(`Security violation: Attempted path traversal to ${filePath}`); + throw new NotFoundException('Invalid file path'); + } + + if (!fs.existsSync(filePath)) { + this.logger.error(`File not found: ${filePath}`); + throw new NotFoundException(`File not found: ${targetPath}`); + } + + const isHtml = targetPath.toLowerCase().endsWith('.html') || targetPath.toLowerCase().endsWith('.htm'); + + // Gebe auch den relativen HTML-Pfad zurück für die Base-Tag-Injection + return { path: filePath, isHtml, htmlRelativePath: targetPath }; + } + + /** + * Validates that a shareId exists and is not expired + */ + async validateShareId(shareId: string): Promise { + const project = await this.prisma.project.findUnique({ + where: { shareid: shareId }, + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + // Check expiry + if (project.expirydate && new Date(project.expirydate) < new Date()) { + throw new NotFoundException('Project has expired'); + } + } + + private getMimeType(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + const mimeTypes: Record = { + '.html': 'text/html', + '.htm': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + '.txt': 'text/plain', + '.xml': 'application/xml', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.eot': 'application/vnd.ms-fontobject', + }; + + return mimeTypes[ext] || 'application/octet-stream'; + } +} diff --git a/nodejs_space/src/services/upload.service.ts b/nodejs_space/src/services/upload.service.ts new file mode 100644 index 0000000..4878158 --- /dev/null +++ b/nodejs_space/src/services/upload.service.ts @@ -0,0 +1,341 @@ +import { Injectable, Logger, BadRequestException, InternalServerErrorException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import AdmZip from 'adm-zip'; +import { createExtractorFromFile } from 'node-unrar-js'; +import { v4 as uuidv4 } from 'uuid'; +import { PrismaService } from './prisma.service'; + +@Injectable() +export class UploadService { + private readonly logger = new Logger(UploadService.name); + private readonly uploadDir: string; + private readonly tempDir: string; + + constructor( + private prisma: PrismaService, + private configService: ConfigService, + ) { + // Read paths from .env with fallbacks + this.uploadDir = this.configService.get('UPLOAD_DIR') || '/var/www/pointcab_webexport_server/uploads/projects'; + this.tempDir = this.configService.get('TEMP_DIR') || '/var/www/pointcab_webexport_server/uploads/temp'; + + this.logger.log(`📁 UploadService using upload directory: ${this.uploadDir}`); + this.logger.log(`📁 UploadService using temp directory: ${this.tempDir}`); + + this.ensureDirectories(); + } + + private async ensureDirectories() { + await fs.ensureDir(this.uploadDir); + await fs.ensureDir(this.tempDir); + } + + // Generiert eine zufällige 8-Zeichen Share-ID (z.B. "abc7x9k2") + private generateShareId(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + } + + async handleFileUpload(files: Express.Multer.File[]): Promise { + this.logger.log(`Received ${files.length} file(s) for upload`); + + let archivePath: string | null = null; + let extractedPath: string | null = null; + + try { + // Check if files are split archives + const isSplitArchive = this.detectSplitArchive(files); + + if (isSplitArchive) { + this.logger.log('Detected split archive, merging files...'); + archivePath = await this.mergeSplitArchive(files); + } else { + // Single file upload + if (files.length !== 1) { + throw new BadRequestException('Please upload either a single archive or all parts of a split archive'); + } + archivePath = files[0].path; + } + + this.logger.log('Starting extraction...'); + // Determine archive type and extract + extractedPath = await this.extractArchive(archivePath); + this.logger.log('Extraction completed'); + + // ===================================================================== + // FIX: Finde alle HTML-Dateien im Stammordner + // - Bei 1 HTML-Datei: speichere den Namen + // - Bei mehreren HTML-Dateien: speichere null (User muss wählen) + // ===================================================================== + const htmlFiles = await this.findAllHtmlFiles(extractedPath); + let htmlFileName: string | null = null; + + if (htmlFiles.length === 0) { + this.logger.warn('No HTML files found in root directory'); + } else if (htmlFiles.length === 1) { + htmlFileName = htmlFiles[0]; + this.logger.log(`✅ Single HTML file found: ${htmlFileName}`); + } else { + this.logger.log(`📄 Multiple HTML files found (${htmlFiles.length}): ${htmlFiles.join(', ')}`); + this.logger.log('User will choose which HTML file to use'); + // htmlFileName bleibt null - User muss später wählen + } + + // Get project name from directory name + const projectName = path.basename(extractedPath); + + // Generate secure password (KLARTEXT - kein Hashing!) + const password = this.generatePassword(); + + // Generate unique share ID + let shareId = this.generateShareId(); + // Ensure shareId is unique + let existingProject = await this.prisma.project.findUnique({ where: { shareid: shareId } }); + while (existingProject) { + shareId = this.generateShareId(); + existingProject = await this.prisma.project.findUnique({ where: { shareid: shareId } }); + } + + // Move extracted folder to final location + const finalPath = path.join(this.uploadDir, projectName); + await fs.move(extractedPath, finalPath, { overwrite: true }); + this.logger.log(`Project files moved to: ${finalPath}`); + + // Clean up temporary files and uploaded archives (save storage space) + this.logger.log('Cleaning up temporary files...'); + + // Delete uploaded archive files + for (const file of files) { + await fs.remove(file.path).catch((err) => { + this.logger.warn(`Failed to remove uploaded file ${file.path}: ${err.message}`); + }); + } + + // Delete merged archive if it was created + if (isSplitArchive && archivePath) { + await fs.remove(archivePath).catch((err) => { + this.logger.warn(`Failed to remove merged archive ${archivePath}: ${err.message}`); + }); + } + + this.logger.log('Cleanup completed'); + + // ===================================================================== + // WICHTIG: Passwort wird im KLARTEXT gespeichert! + // Kein bcrypt.hash() - das Passwort wird direkt gespeichert. + // ===================================================================== + const project = await this.prisma.project.create({ + data: { + name: projectName, + shareid: shareId, + password: password, // KLARTEXT - kein Hashing! + htmlfilename: htmlFileName, // null bei mehreren HTML-Dateien + uploaddate: new Date(), + }, + }); + + this.logger.log(`Project '${projectName}' uploaded successfully with share ID: ${shareId}`); + this.logger.log(`Password stored as plain text: ${password}`); + + return { + id: project.id, + name: project.name, + shareid: project.shareid, + password: password, // Klartext-Passwort zurückgeben + htmlFileName: project.htmlfilename, + htmlFiles: htmlFiles, // Alle gefundenen HTML-Dateien + uploadDate: project.uploaddate, + link: `/${shareId}`, + }; + } catch (error) { + this.logger.error(`Upload failed: ${error.message}`, error.stack); + + // Clean up on error + for (const file of files) { + await fs.remove(file.path).catch(() => {}); + } + + if (archivePath) { + await fs.remove(archivePath).catch(() => {}); + } + + if (extractedPath) { + await fs.remove(extractedPath).catch(() => {}); + } + + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException(`Upload failed: ${error.message}`); + } + } + + private detectSplitArchive(files: Express.Multer.File[]): boolean { + if (files.length <= 1) return false; + + const fileNames = files.map(f => f.originalname); + + // Check for .zip.001, .zip.002, etc. + const zipPattern = /\.zip\.\d{3}$/i; + const hasZipSplit = fileNames.some(name => zipPattern.test(name)); + + // Check for .rar.part1, .rar.part2, etc. or .part01.rar, .part02.rar + const rarPattern = /\.(rar\.part\d+|part\d+\.rar)$/i; + const hasRarSplit = fileNames.some(name => rarPattern.test(name)); + + return hasZipSplit || hasRarSplit; + } + + private async mergeSplitArchive(files: Express.Multer.File[]): Promise { + // Sort files by name to ensure correct order + const sortedFiles = files.sort((a, b) => a.originalname.localeCompare(b.originalname)); + + const mergedFileName = `merged-${uuidv4()}.archive`; + const mergedPath = path.join(this.tempDir, mergedFileName); + + const writeStream = fs.createWriteStream(mergedPath); + + for (const file of sortedFiles) { + const data = await fs.readFile(file.path); + writeStream.write(data); + this.logger.log(`Merged part: ${file.originalname}`); + } + + writeStream.end(); + + return new Promise((resolve, reject) => { + writeStream.on('finish', () => { + this.logger.log('Split archive merged successfully'); + resolve(mergedPath); + }); + writeStream.on('error', reject); + }); + } + + private async extractArchive(archivePath: string): Promise { + const fileName = path.basename(archivePath); + const isZip = fileName.toLowerCase().endsWith('.zip') || + /\.zip\.\d{3}$/i.test(fileName) || + fileName.includes('merged'); + + const extractPath = path.join(this.tempDir, `extracted-${uuidv4()}`); + await fs.ensureDir(extractPath); + + if (isZip) { + this.logger.log('Extracting ZIP archive...'); + await this.extractZip(archivePath, extractPath); + } else { + this.logger.log('Extracting RAR archive...'); + await this.extractRar(archivePath, extractPath); + } + + // Find the actual project directory (might be nested) + const contents = await fs.readdir(extractPath); + + // If there's only one directory, use that as the project root + if (contents.length === 1) { + const singleItem = path.join(extractPath, contents[0]); + const stat = await fs.stat(singleItem); + if (stat.isDirectory()) { + return singleItem; + } + } + + return extractPath; + } + + private async extractZip(archivePath: string, extractPath: string): Promise { + try { + const zip = new AdmZip(archivePath); + zip.extractAllTo(extractPath, true); + this.logger.log('ZIP extraction completed'); + } catch (error) { + this.logger.error(`ZIP extraction failed: ${error.message}`); + throw new BadRequestException('Failed to extract ZIP archive'); + } + } + + private async extractRar(archivePath: string, extractPath: string): Promise { + try { + const extractor = await createExtractorFromFile({ filepath: archivePath, targetPath: extractPath }); + const extracted = extractor.extract(); + + const files = [...extracted.files]; + this.logger.log(`Extracted ${files.length} files from RAR archive`); + + if (files.length === 0) { + throw new Error('No files extracted from RAR archive'); + } + } catch (error) { + this.logger.error(`RAR extraction failed: ${error.message}`); + throw new BadRequestException('Failed to extract RAR archive'); + } + } + + /** + * ===================================================================== + * NEU: Findet ALLE HTML-Dateien im Stammordner + * + * Rückgabe: + * - Leeres Array: Keine HTML-Dateien gefunden + * - 1 Element: Genau eine HTML-Datei (wird automatisch verwendet) + * - Mehrere Elemente: User muss wählen + * ===================================================================== + */ + private async findAllHtmlFiles(directoryPath: string): Promise { + const files = await fs.readdir(directoryPath); + const htmlFiles = files.filter(file => { + const lower = file.toLowerCase(); + return lower.endsWith('.html') || lower.endsWith('.htm'); + }); + + this.logger.log(`Found ${htmlFiles.length} HTML file(s) in root: ${htmlFiles.join(', ') || 'none'}`); + return htmlFiles; + } + + /** + * Alte Methode für Abwärtskompatibilität (wirft Fehler wenn keine HTML gefunden) + */ + private async findHtmlFile(directoryPath: string): Promise { + const htmlFiles = await this.findAllHtmlFiles(directoryPath); + + if (htmlFiles.length === 0) { + throw new BadRequestException('No HTML file found in the root of the extracted archive'); + } + + // Wenn mehrere HTML-Dateien, nimm die erste (oder User wählt später) + this.logger.log(`Using HTML file: ${htmlFiles[0]}`); + return htmlFiles[0]; + } + + /** + * Generiert ein lesbares 8-Zeichen Passwort + * WICHTIG: Dieses Passwort wird im KLARTEXT gespeichert! + */ + private generatePassword(): string { + return uuidv4().split('-')[0]; + } + + async deleteProject(projectId: string): Promise { + const project = await this.prisma.project.findUnique({ where: { id: projectId } }); + + if (!project) { + throw new BadRequestException('Project not found'); + } + + // Delete files + const projectPath = path.join(this.uploadDir, project.name); + await fs.remove(projectPath); + + // Delete from database + await this.prisma.project.delete({ where: { id: projectId } }); + + this.logger.log(`Project '${project.name}' deleted successfully`); + } +} diff --git a/nodejs_space/tsconfig.json b/nodejs_space/tsconfig.json new file mode 100644 index 0000000..dc0044d --- /dev/null +++ b/nodejs_space/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": [ + "node_modules", + "dist", + "backups", + "backups/**/*", + "**/*.backup.ts", + "**/*.bak" + ] +} diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh new file mode 100755 index 0000000..8321bf6 --- /dev/null +++ b/scripts/cleanup.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# PointCab Webexport Server - Bereinigungsscript + +set -e + +# Farben +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +SERVER_DIR="/var/www/pointcab_webexport_server" +BACKUP_DIR="$SERVER_DIR/backups" +LOG_RETENTION_DAYS=7 +BACKUP_RETENTION_DAYS=30 + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}PointCab Webexport - Bereinigung${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" + +# Root-Check +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Bitte als Root ausführen: sudo ./cleanup.sh${NC}" + exit 1 +fi + +echo -e "${YELLOW}[1/5] PM2 Logs rotieren...${NC}" +pm2 flush +echo -e "${GREEN}✓ PM2 Logs geleert${NC}" + +echo -e "\n${YELLOW}[2/5] Alte Backups löschen (älter als $BACKUP_RETENTION_DAYS Tage)...${NC}" +if [ -d "$BACKUP_DIR" ]; then + OLD_BACKUPS=$(find "$BACKUP_DIR" -maxdepth 1 -type d -mtime +$BACKUP_RETENTION_DAYS 2>/dev/null | wc -l) + if [ "$OLD_BACKUPS" -gt 0 ]; then + find "$BACKUP_DIR" -maxdepth 1 -type d -mtime +$BACKUP_RETENTION_DAYS -exec rm -rf {} \; + echo -e "${GREEN}✓ $OLD_BACKUPS alte Backups gelöscht${NC}" + else + echo -e "${GREEN}✓ Keine alten Backups gefunden${NC}" + fi +else + echo -e "${YELLOW}⚠ Backup-Verzeichnis nicht gefunden${NC}" +fi + +echo -e "\n${YELLOW}[3/5] System-Logs bereinigen (älter als $LOG_RETENTION_DAYS Tage)...${NC}" +find /var/log -name "*.log" -mtime +$LOG_RETENTION_DAYS -delete 2>/dev/null || true +find /var/log -name "*.gz" -mtime +$LOG_RETENTION_DAYS -delete 2>/dev/null || true +echo -e "${GREEN}✓ System-Logs bereinigt${NC}" + +echo -e "\n${YELLOW}[4/5] Temporäre Dateien löschen...${NC}" +rm -rf /tmp/upload_* 2>/dev/null || true +rm -rf /tmp/tmp-* 2>/dev/null || true +echo -e "${GREEN}✓ Temporäre Dateien gelöscht${NC}" + +echo -e "\n${YELLOW}[5/5] npm Cache leeren...${NC}" +npm cache clean --force 2>/dev/null || true +echo -e "${GREEN}✓ npm Cache geleert${NC}" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Bereinigung abgeschlossen!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" + +# Speicherplatz anzeigen +echo -e "${YELLOW}Aktueller Speicherplatz:${NC}" +df -h / +echo "" + +echo -e "${YELLOW}Verzeichnisgrößen:${NC}" +du -sh $SERVER_DIR/nodejs_space/uploads 2>/dev/null || echo "uploads: nicht gefunden" +du -sh $SERVER_DIR/backups 2>/dev/null || echo "backups: nicht gefunden" \ No newline at end of file diff --git a/scripts/db-check.sh b/scripts/db-check.sh new file mode 100755 index 0000000..896be7d --- /dev/null +++ b/scripts/db-check.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# PointCab Webexport Server - Datenbank-Prüfungsscript + +# Farben +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +DB_NAME="pointcab_db" +DB_USER="pointcab_user" + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}PointCab Webexport - Datenbank-Prüfung${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" + +# Root-Check +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Bitte als Root ausführen: sudo ./db-check.sh${NC}" + exit 1 +fi + +echo -e "${YELLOW}[1/6] PostgreSQL Status...${NC}" +if systemctl is-active --quiet postgresql; then + echo -e "${GREEN}✓ PostgreSQL läuft${NC}" +else + echo -e "${RED}✗ PostgreSQL läuft NICHT${NC}" + exit 1 +fi + +echo -e "\n${YELLOW}[2/6] Datenbank-Verbindung prüfen...${NC}" +if sudo -u postgres psql -d $DB_NAME -c "SELECT 1" > /dev/null 2>&1; then + echo -e "${GREEN}✓ Verbindung zu $DB_NAME erfolgreich${NC}" +else + echo -e "${RED}✗ Verbindung zu $DB_NAME fehlgeschlagen${NC}" + exit 1 +fi + +echo -e "\n${YELLOW}[3/6] Benutzer prüfen...${NC}" +USER_EXISTS=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'") +if [ "$USER_EXISTS" = "1" ]; then + echo -e "${GREEN}✓ Benutzer $DB_USER existiert${NC}" +else + echo -e "${RED}✗ Benutzer $DB_USER nicht gefunden${NC}" +fi + +echo -e "\n${YELLOW}[4/6] Schema prüfen...${NC}" +echo -e "${YELLOW}Tabellen:${NC}" +sudo -u postgres psql -d $DB_NAME -c "\dt" 2>/dev/null || echo "Keine Tabellen gefunden" + +echo -e "\n${YELLOW}project-Tabelle Schema:${NC}" +sudo -u postgres psql -d $DB_NAME -c "\d project" 2>/dev/null || echo "Tabelle 'project' nicht gefunden" + +echo -e "\n${YELLOW}[5/6] htmlfilename Nullable-Status prüfen...${NC}" +NULLABLE=$(sudo -u postgres psql -d $DB_NAME -tAc "SELECT is_nullable FROM information_schema.columns WHERE table_name='project' AND column_name='htmlfilename';") +if [ "$NULLABLE" = "YES" ]; then + echo -e "${GREEN}✓ htmlfilename ist nullable (korrekt)${NC}" +else + echo -e "${RED}✗ htmlfilename ist NICHT nullable - Multi-HTML funktioniert nicht!${NC}" + echo -e "${YELLOW}Fix: ALTER TABLE project ALTER COLUMN htmlfilename DROP NOT NULL;${NC}" +fi + +echo -e "\n${YELLOW}[6/6] Statistiken...${NC}" +echo -e "${YELLOW}Anzahl Projekte:${NC}" +sudo -u postgres psql -d $DB_NAME -tAc "SELECT COUNT(*) FROM project;" 2>/dev/null || echo "0" + +echo -e "\n${YELLOW}Datenbank-Größe:${NC}" +sudo -u postgres psql -tAc "SELECT pg_size_pretty(pg_database_size('$DB_NAME'));" + +echo -e "\n${YELLOW}Abgelaufene Projekte:${NC}" +sudo -u postgres psql -d $DB_NAME -c "SELECT id, name, shareid, expirydate FROM project WHERE expirydate < NOW();" 2>/dev/null || echo "Keine abgelaufenen Projekte" + +echo -e "\n${YELLOW}Alle Datenbanken:${NC}" +sudo -u postgres psql -c "\l" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Datenbank-Prüfung abgeschlossen!${NC}" +echo -e "${GREEN}========================================${NC}" \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..2223121 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# PointCab Webexport Server - Deployment Script + +set -e + +# Farben +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +SERVER_DIR="/var/www/pointcab_webexport_server" +NODEJS_DIR="$SERVER_DIR/nodejs_space" +BACKUP_DIR="$SERVER_DIR/backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}PointCab Webexport - Deployment${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" + +# Root-Check +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Bitte als Root ausführen: sudo ./deploy.sh${NC}" + exit 1 +fi + +# Verzeichnis prüfen +if [ ! -d "$NODEJS_DIR" ]; then + echo -e "${RED}Fehler: Server-Verzeichnis nicht gefunden!${NC}" + echo "Erwartet: $NODEJS_DIR" + exit 1 +fi + +echo -e "${YELLOW}[1/5] Backup erstellen...${NC}" +mkdir -p "$BACKUP_DIR/$TIMESTAMP" +cp -r "$NODEJS_DIR/src" "$BACKUP_DIR/$TIMESTAMP/" +cp -r "$NODEJS_DIR/dist" "$BACKUP_DIR/$TIMESTAMP/" 2>/dev/null || true +echo -e "${GREEN}✓ Backup erstellt: $BACKUP_DIR/$TIMESTAMP${NC}" + +# Rollback-Script erstellen +cat > "$BACKUP_DIR/$TIMESTAMP/rollback.sh" << EOF +#!/bin/bash +echo "Rollback zu $TIMESTAMP..." +cp -r $BACKUP_DIR/$TIMESTAMP/src $NODEJS_DIR/ +cp -r $BACKUP_DIR/$TIMESTAMP/dist $NODEJS_DIR/ +pm2 restart pointcab-server +echo "Rollback abgeschlossen." +EOF +chmod +x "$BACKUP_DIR/$TIMESTAMP/rollback.sh" + +echo -e "\n${YELLOW}[2/5] Neue Dateien kopieren...${NC}" +# Hier würde git pull oder scp stattfinden +if [ -d "./nodejs_space" ]; then + cp -r ./nodejs_space/src/* "$NODEJS_DIR/src/" + echo -e "${GREEN}✓ Dateien kopiert${NC}" +else + echo -e "${YELLOW}⚠ Keine neuen Dateien gefunden, verwende Git...${NC}" + cd "$NODEJS_DIR" + git pull origin main 2>/dev/null || echo "Git nicht konfiguriert" +fi + +echo -e "\n${YELLOW}[3/5] Abhängigkeiten aktualisieren...${NC}" +cd "$NODEJS_DIR" +npm install +echo -e "${GREEN}✓ Abhängigkeiten aktualisiert${NC}" + +echo -e "\n${YELLOW}[4/5] TypeScript kompilieren...${NC}" +npm run build +echo -e "${GREEN}✓ Kompilierung abgeschlossen${NC}" + +echo -e "\n${YELLOW}[5/5] PM2 neustarten...${NC}" +pm2 restart pointcab-server +echo -e "${GREEN}✓ Server neugestartet${NC}" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Deployment abgeschlossen!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" + +# Status anzeigen +pm2 status + +echo "" +echo -e "Health-Check: ${YELLOW}curl http://localhost:3000/health${NC}" +echo -e "Logs: ${YELLOW}pm2 logs pointcab-server${NC}" +echo -e "Rollback: ${YELLOW}$BACKUP_DIR/$TIMESTAMP/rollback.sh${NC}" \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..5b20f32 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# PointCab Webexport Server - Installationsscript +# Für Ubuntu 24.04 LTS + +set -e + +# Farben für Output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}PointCab Webexport Server Installation${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" + +# Root-Check +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Bitte als Root ausführen: sudo ./install.sh${NC}" + exit 1 +fi + +# Variablen +INSTALL_DIR="/var/www/pointcab_webexport_server" +DB_NAME="pointcab_db" +DB_USER="pointcab_user" + +# Passwort abfragen +read -sp "PostgreSQL-Passwort für $DB_USER: " DB_PASS +echo "" +read -sp "Admin-Passwort für Dashboard: " ADMIN_PASS +echo "" +read -sp "Session-Secret (mind. 32 Zeichen): " SESSION_SECRET +echo "" + +echo -e "\n${YELLOW}[1/8] System aktualisieren...${NC}" +apt update && apt upgrade -y + +echo -e "\n${YELLOW}[2/8] PostgreSQL installieren...${NC}" +apt install -y postgresql postgresql-contrib +systemctl start postgresql +systemctl enable postgresql + +echo -e "\n${YELLOW}[3/8] Datenbank einrichten...${NC}" +sudo -u postgres psql < $INSTALL_DIR/nodejs_space/.env << EOF +PORT=3000 +NODE_ENV=production +DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME" +UPLOAD_DIR=$INSTALL_DIR/nodejs_space/uploads +SESSION_SECRET=$SESSION_SECRET +ADMIN_PASSWORD=$ADMIN_PASS +EOF + +echo -e "\n${YELLOW}[7/8] Abhängigkeiten installieren und kompilieren...${NC}" +cd $INSTALL_DIR/nodejs_space +npm install +npx prisma generate +npx prisma db push +npm run build + +echo -e "\n${YELLOW}[8/8] PM2 starten...${NC}" +pm2 start dist/main.js --name pointcab-server +pm2 startup +pm2 save + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Installation abgeschlossen!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo -e "Server läuft auf: ${YELLOW}http://localhost:3000${NC}" +echo -e "Admin-Dashboard: ${YELLOW}http://localhost:3000/admin/dashboard${NC}" +echo "" +echo -e "PM2 Status: ${YELLOW}pm2 status${NC}" +echo -e "PM2 Logs: ${YELLOW}pm2 logs pointcab-server${NC}" +echo "" \ No newline at end of file