From 013eb78a7ccfc103534f32a09cfcc37464605f32 Mon Sep 17 00:00:00 2001 From: Sebastian Zell Date: Fri, 16 Jan 2026 10:14:11 +0100 Subject: [PATCH] Initial commit --- DEPLOYMENT.md | 259 ++++++ GITEA_WORKFLOW.md | 376 ++++++++ INSTALLATION.md | 290 ++++++ LICENSE | 21 + MAINTENANCE.md | 345 +++++++ README.md | 101 ++ USER_GUIDE.md | 278 ++++++ docs/ARCHITECTURE.md | 275 ++++++ docs/ARCHITECTURE.pdf | Bin 0 -> 83017 bytes docs/CHANGELOG.md | 73 ++ nodejs_space/.env.example | 19 + nodejs_space/package.json | 106 +++ nodejs_space/prisma/schema.prisma | 22 + .../src/controllers/admin.controller.ts | 174 ++++ .../src/controllers/projects.controller.ts | 385 ++++++++ .../src/controllers/root.controller.ts | 870 ++++++++++++++++++ nodejs_space/src/services/admin.service.ts | 402 ++++++++ nodejs_space/src/services/prisma.service.ts | 13 + nodejs_space/src/services/projects.service.ts | 300 ++++++ nodejs_space/src/services/upload.service.ts | 341 +++++++ nodejs_space/tsconfig.json | 33 + scripts/cleanup.sh | 72 ++ scripts/db-check.sh | 80 ++ scripts/deploy.sh | 88 ++ scripts/install.sh | 97 ++ 25 files changed, 5020 insertions(+) create mode 100644 DEPLOYMENT.md create mode 100644 GITEA_WORKFLOW.md create mode 100644 INSTALLATION.md create mode 100644 LICENSE create mode 100644 MAINTENANCE.md create mode 100644 README.md create mode 100644 USER_GUIDE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/ARCHITECTURE.pdf create mode 100644 docs/CHANGELOG.md create mode 100644 nodejs_space/.env.example create mode 100644 nodejs_space/package.json create mode 100644 nodejs_space/prisma/schema.prisma create mode 100644 nodejs_space/src/controllers/admin.controller.ts create mode 100644 nodejs_space/src/controllers/projects.controller.ts create mode 100644 nodejs_space/src/controllers/root.controller.ts create mode 100644 nodejs_space/src/services/admin.service.ts create mode 100644 nodejs_space/src/services/prisma.service.ts create mode 100644 nodejs_space/src/services/projects.service.ts create mode 100644 nodejs_space/src/services/upload.service.ts create mode 100644 nodejs_space/tsconfig.json create mode 100755 scripts/cleanup.sh create mode 100755 scripts/db-check.sh create mode 100755 scripts/deploy.sh create mode 100755 scripts/install.sh 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 0000000000000000000000000000000000000000..327d51647f2c4d9d29581ca5fa9ea052f9c158ca GIT binary patch literal 83017 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 Y7q^ct<`yV?9NauyC^R(EDl#bl0gL!QegFUf literal 0 HcmV?d00001 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