Initial commit

This commit is contained in:
Sebastian Zell 2026-01-16 10:14:11 +01:00
commit 013eb78a7c
25 changed files with 5020 additions and 0 deletions

259
DEPLOYMENT.md Normal file
View File

@ -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

376
GITEA_WORKFLOW.md Normal file
View File

@ -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
```
<typ>(<bereich>): <kurze beschreibung>
<optionale längere beschreibung>
<optionale referenzen>
```
### 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)

290
INSTALLATION.md Normal file
View File

@ -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 <PID>
```
---
**Nächster Schritt:** [DEPLOYMENT.md](DEPLOYMENT.md)

21
LICENSE Normal file
View File

@ -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.

345
MAINTENANCE.md Normal file
View File

@ -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

101
README.md Normal file
View File

@ -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.

278
USER_GUIDE.md Normal file
View File

@ -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

275
docs/ARCHITECTURE.md Normal file
View File

@ -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 <base href> für korrekte Asset-Pfade ein
```
#### AdminService (`admin.service.ts`)
**Kernfunktionen:**
1. **RAR-Entpacken:**
```typescript
extractRar(projectId: string, rarPath: string): Promise<void>
// 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<void>
// 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)

BIN
docs/ARCHITECTURE.pdf Normal file

Binary file not shown.

73
docs/CHANGELOG.md Normal file
View File

@ -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)

19
nodejs_space/.env.example Normal file
View File

@ -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

106
nodejs_space/package.json Normal file
View File

@ -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"
}

View File

@ -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())
}

View File

@ -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);
}
}

View File

@ -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 = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PointCab Webexport - Passwort erforderlich</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #0066cc 0%, #004c99 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
max-width: 400px;
width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 { color: #0066cc; margin-bottom: 10px; font-size: 1.8em; }
p { color: #666; margin-bottom: 30px; }
input {
width: 100%;
padding: 14px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1em;
margin-bottom: 20px;
}
input:focus { outline: none; border-color: #0066cc; }
button {
width: 100%;
background: #0066cc;
color: white;
padding: 14px;
border: none;
border-radius: 8px;
font-size: 1.1em;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
button:hover { background: #0052a3; }
.error {
background: #fee;
color: #c33;
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.error.show { display: block; }
</style>
</head>
<body>
<div class="container">
<h1>🔒 PointCab Webexport</h1>
<p>Dieses Projekt ist passwortgeschützt.</p>
<div id="error" class="error"></div>
<form id="passwordForm">
<input type="password" id="password" placeholder="Passwort eingeben" required autofocus />
<button type="submit">Zugriff entsperren</button>
</form>
</div>
<script>
document.getElementById('passwordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const errorDiv = document.getElementById('error');
try {
const res = await fetch('/${shareId}/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (res.ok) {
window.location.href = '/${shareId}/view';
} else {
const data = await res.json();
errorDiv.textContent = data.message || 'Ungültiges Passwort';
errorDiv.classList.add('show');
}
} catch (error) {
errorDiv.textContent = 'Verbindungsfehler';
errorDiv.classList.add('show');
}
});
</script>
</body>
</html>`;
return res.send(html);
} catch (error) {
return res.status(404).send('<h1>404 - Project not found</h1>');
}
}
@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 = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML-Datei auswählen</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: #2b2b2b;
color: white;
padding: 40px 20px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #0066cc; margin-bottom: 10px; }
p { color: #aaa; margin-bottom: 30px; }
.info-box {
background: #1a3a5c;
border: 1px solid #0066cc;
border-radius: 8px;
padding: 15px;
margin-bottom: 30px;
}
.info-box p { margin: 0; color: #88c4ff; }
.file-list {
background: #3a3a3a;
border-radius: 12px;
padding: 20px;
border: 1px solid #444;
}
.file-item {
background: #444;
padding: 20px;
margin-bottom: 15px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s;
border: 2px solid transparent;
}
.file-item:hover {
background: #505050;
border-color: #0066cc;
}
.file-name {
font-size: 1.2em;
font-weight: 600;
color: #fff;
}
.file-icon { font-size: 2em; margin-right: 15px; }
.btn {
background: #0066cc;
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.btn:hover {
background: #0052a3;
transform: translateY(-2px);
}
</style>
</head>
<body>
<div class="container">
<h1>📄 Mehrere HTML-Dateien gefunden</h1>
<p>Bitte wählen Sie die Datei aus, die Sie öffnen möchten:</p>
<div class="info-box">
<p> Diese Auswahl wird bei jedem Besuch angezeigt - Sie können frei zwischen den Etagen/Dokumentationen wechseln.</p>
</div>
<div class="file-list">
${htmlFiles.map(file => `
<div class="file-item">
<div style="display: flex; align-items: center;">
<span class="file-icon">📄</span>
<span class="file-name">${file}</span>
</div>
<a href="/${shareId}/view?html=${encodeURIComponent(file)}" class="btn">Öffnen</a>
</div>
`).join('')}
</div>
</div>
</body>
</html>`;
return res.send(html);
} catch (error) {
return res.status(404).send('<h1>404 - Project not found</h1>');
}
}
/**
* 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 <base> 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<string, string> = {
'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 <base> 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 });
}
}
}

View File

@ -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 = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PointCab Webexport Server - Admin Login</title>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #0066cc 0%, #004c99 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 50px 40px;
max-width: 450px;
width: 100%;
}
.logo {
text-align: center;
margin-bottom: 40px;
}
.logo h1 {
color: #0066cc;
font-size: 2em;
font-weight: 700;
margin-bottom: 10px;
}
.logo-accent { color: #00d4ff; }
.logo p { color: #666; font-size: 0.95em; }
.form-group { margin-bottom: 25px; }
label {
display: block;
color: #333;
font-weight: 600;
margin-bottom: 8px;
font-size: 0.95em;
}
input {
width: 100%;
padding: 14px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1em;
font-family: inherit;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #0066cc;
}
.btn-login {
width: 100%;
background: linear-gradient(135deg, #0066cc 0%, #004c99 100%);
color: white;
padding: 16px;
border: none;
border-radius: 8px;
font-size: 1.1em;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 15px rgba(0, 102, 204, 0.3);
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 102, 204, 0.5);
}
.btn-login:active { transform: translateY(0); }
.error-message {
background: #fee;
color: #c33;
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
font-size: 0.9em;
}
.error-message.show { display: block; }
.loading { pointer-events: none; opacity: 0.7; }
@media (max-width: 480px) {
.login-container { padding: 40px 25px; }
}
</style>
</head>
<body>
<div class="login-container">
<div class="logo">
<h1>Point<span class="logo-accent">Cab</span></h1>
<p>Webexport Server</p>
</div>
<div class="error-message" id="errorMessage"></div>
<form id="loginForm">
<div class="form-group">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn-login" id="loginBtn">Anmelden</button>
</form>
</div>
<script>
const form = document.getElementById('loginForm');
const errorMessage = document.getElementById('errorMessage');
const loginBtn = document.getElementById('loginBtn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
errorMessage.classList.remove('show');
loginBtn.classList.add('loading');
loginBtn.textContent = 'Anmeldung läuft...';
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
localStorage.setItem('adminToken', data.token);
window.location.href = '/admin/dashboard';
} else {
errorMessage.textContent = data.message || 'Anmeldung fehlgeschlagen';
errorMessage.classList.add('show');
}
} catch (error) {
errorMessage.textContent = 'Verbindungsfehler. Bitte versuchen Sie es erneut.';
errorMessage.classList.add('show');
} finally {
loginBtn.classList.remove('loading');
loginBtn.textContent = 'Anmelden';
}
});
</script>
</body>
</html>`;
res.send(html);
}
@Get('admin/dashboard')
getDashboard(@Res() res: Response) {
res.send(this.getDashboardHTML());
}
private getDashboardHTML(): string {
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PointCab Admin Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
/* PointCab Farbschema: Dunkelgrauer Hintergrund, Blaue Buttons, Weißer Text */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: #2b2b2b; /* PointCab Grau */
color: #ffffff; /* Weißer Text */
}
.header {
background: linear-gradient(135deg, #0066cc 0%, #004c99 100%);
color: white;
padding: 20px 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo h1 { font-size: 1.5em; font-weight: 700; color: white; }
.logo-accent { color: #00d4ff; }
.logout-btn {
background: rgba(255,255,255,0.2);
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: background 0.3s;
}
.logout-btn:hover { background: rgba(255,255,255,0.3); }
.container { max-width: 1400px; margin: 40px auto; padding: 0 30px; }
.tabs {
display: flex;
gap: 10px;
margin-bottom: 30px;
border-bottom: 2px solid #444;
}
.tab {
padding: 15px 30px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 1em;
font-weight: 600;
color: #aaa;
transition: all 0.3s;
}
.tab:hover { color: #0066cc; }
.tab.active { color: #0066cc; border-bottom-color: #0066cc; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.card {
background: #3a3a3a; /* Dunkelgrau für Cards */
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
margin-bottom: 30px;
border: 1px solid #444;
}
.card h2 {
color: #0066cc; /* Blaue Überschriften */
margin-bottom: 20px;
font-size: 1.5em;
}
.upload-area {
border: 3px dashed #555;
border-radius: 12px;
padding: 60px 30px;
text-align: center;
transition: all 0.3s;
cursor: pointer;
background: #333;
}
.upload-area:hover, .upload-area.dragover {
border-color: #0066cc;
background: #3a4a5a;
}
.upload-area .icon { font-size: 4em; margin-bottom: 20px; color: #0066cc; }
.upload-area h3 { color: #fff; margin-bottom: 10px; }
.upload-area p { color: #aaa; }
input[type="file"] { display: none; }
.project-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.project-table th {
background: #444;
padding: 15px;
text-align: left;
font-weight: 600;
color: #fff;
border-bottom: 2px solid #555;
}
.project-table td {
padding: 15px;
border-bottom: 1px solid #444;
color: #fff;
}
.project-table tr:hover { background: #404040; }
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s;
margin-right: 5px;
}
.btn-primary { background: #0066cc; color: white; } /* Blaue Buttons */
.btn-primary:hover { background: #0052a3; transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,102,204,0.3); }
.btn-danger { background: #dc3545; color: white; }
.btn-danger:hover { background: #c82333; transform: translateY(-1px); }
.btn-copy { background: #0066cc; color: white; } /* Copy-Button auch blau */
.btn-copy:hover { background: #0052a3; transform: translateY(-1px); }
.btn-warning { background: #ff9800; color: white; }
.btn-warning:hover { background: #e68900; transform: translateY(-1px); }
.share-link {
font-family: monospace;
background: #444;
padding: 10px;
border-radius: 6px;
word-break: break-all;
color: #00d4ff;
border: 1px solid #555;
}
.loading { text-align: center; padding: 40px; color: #aaa; }
.error { color: #ff6b6b; padding: 15px; background: #3a2020; border-radius: 6px; margin: 20px 0; border: 1px solid #663333; }
.success { color: #51cf66; padding: 15px; background: #203a20; border-radius: 6px; margin: 20px 0; border: 1px solid #336633; }
.progress-bar {
width: 100%;
height: 30px;
background: #444;
border-radius: 15px;
overflow: hidden;
margin: 20px 0;
display: none;
border: 1px solid #555;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #0066cc, #00d4ff); /* Fortschrittsbalken bleibt */
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
}
.btn-manual-project {
background: #0066cc; /* Auch blau */
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
margin-top: 20px;
transition: all 0.3s;
}
.btn-manual-project:hover {
background: #0052a3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,102,204,0.3);
}
</style>
</head>
<body>
<div class="header">
<div class="logo">
<h1>Point<span class="logo-accent">Cab</span> Admin</h1>
</div>
<button class="logout-btn" id="logoutBtn">Abmelden</button>
</div>
<div class="container">
<div class="tabs">
<button class="tab active" data-tab="upload">📤 Upload</button>
<button class="tab" data-tab="projects">🗂 Projekte verwalten</button>
<button class="tab" data-tab="links">🔗 Links teilen</button>
</div>
<!-- Upload Tab -->
<div id="upload-tab" class="tab-content active">
<div class="card">
<h2>Projekt hochladen</h2>
<div class="upload-area" id="uploadArea">
<div class="icon">📦</div>
<h3>Datei hier ablegen oder klicken zum Auswählen</h3>
<p>ZIP oder RAR Archive (auch gesplittete Archive)</p>
</div>
<input type="file" id="fileInput" multiple accept=".zip,.rar,.001,.part1">
<div class="progress-bar" id="progressBar">
<div class="progress-fill" id="progressFill">0%</div>
</div>
<div id="uploadMessage"></div>
<div style="text-align: center; margin-top: 30px; padding-top: 30px; border-top: 2px solid #e0e0e0;">
<button class="btn-manual-project" id="btnCreateManual">
📁 Projekt manuell anlegen (für FTP-Upload)
</button>
<p style="color: #666; margin-top: 10px; font-size: 0.9em;">
Erstellt ein leeres Projekt, in das Sie Dateien per FTP hochladen können
</p>
</div>
</div>
</div>
<!-- Projects Tab -->
<div id="projects-tab" class="tab-content">
<div class="card">
<h2>Alle Projekte</h2>
<div id="projectsContent">
<div class="loading">Projekte werden geladen...</div>
</div>
</div>
</div>
<!-- Links Tab -->
<div id="links-tab" class="tab-content">
<div class="card">
<h2>Projekt-Links teilen</h2>
<div id="linksContent">
<div class="loading">Links werden geladen...</div>
</div>
</div>
</div>
</div>
<script>
const token = localStorage.getItem('adminToken');
if (!token) {
window.location.href = '/';
}
function logout() {
localStorage.removeItem('adminToken');
window.location.href = '/';
}
// Attach logout button event listener
document.getElementById('logoutBtn').addEventListener('click', logout);
function switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
// Find the button that was clicked
const buttons = document.querySelectorAll('.tab');
const tabNames = ['upload', 'projects', 'links'];
const index = tabNames.indexOf(tabName);
if (index !== -1 && buttons[index]) {
buttons[index].classList.add('active');
}
document.getElementById(tabName + '-tab').classList.add('active');
if (tabName === 'projects') loadProjects();
if (tabName === 'links') loadLinks();
}
// Attach event listeners to tab buttons
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
// Upload functionality
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
const uploadMessage = document.getElementById('uploadMessage');
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
async function handleFiles(files) {
if (files.length === 0) return;
const formData = new FormData();
let totalSize = 0;
for (let file of files) {
formData.append('files', file);
totalSize += file.size;
}
// Show size info
const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
uploadMessage.innerHTML = '<div class="loading">📤 Bereite Upload vor... (' + sizeMB + ' MB)</div>';
progressBar.style.display = 'block';
progressFill.style.width = '30%';
progressFill.textContent = 'Uploading...';
try {
const uploadStart = Date.now();
const response = await fetch('/api/admin/upload', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token
},
body: formData
});
// Show extraction progress
progressFill.style.width = '70%';
progressFill.textContent = 'Entpacken...';
uploadMessage.innerHTML = '<div class="loading">📦 Archiv wird entpackt...</div>';
const data = await response.json();
if (response.ok) {
const uploadTime = ((Date.now() - uploadStart) / 1000).toFixed(1);
progressFill.style.width = '100%';
progressFill.textContent = '✅ Fertig!';
uploadMessage.innerHTML = '<div class="success">✅ Projekt erfolgreich hochgeladen! (in ' + uploadTime + 's)<br><strong>Projektname:</strong> ' + data.name + '<br><strong>Passwort:</strong> <code>' + data.password + '</code><br><strong>Link:</strong> <a href="' + data.link + '" target="_blank">' + window.location.origin + data.link + '</a></div>';
// Reload projects to show the new one
if (document.getElementById('projects-tab').classList.contains('active')) {
loadProjects();
}
} else {
uploadMessage.innerHTML = '<div class="error">❌ ' + (data.message || 'Upload fehlgeschlagen') + '</div>';
progressBar.style.display = 'none';
}
} catch (error) {
console.error('Upload error:', error);
uploadMessage.innerHTML = '<div class="error">❌ Upload fehlgeschlagen: ' + error.message + '</div>';
progressBar.style.display = 'none';
} finally {
setTimeout(() => {
progressFill.style.width = '0%';
fileInput.value = '';
}, 5000);
}
}
// Manual project creation
document.getElementById('btnCreateManual').addEventListener('click', async () => {
const projectName = prompt('Projektname eingeben (optional):');
if (projectName === null) return; // User cancelled
try {
const response = await fetch('/api/admin/projects/create-manual', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ projectName: projectName || undefined })
});
const data = await response.json();
if (response.ok) {
alert('✅ Manuelles Projekt erstellt!\\n\\nName: ' + data.name + '\\nPasswort: ' + data.password + '\\nPfad: ' + data.projectPath + '\\n\\nSie können jetzt Dateien per FTP in dieses Verzeichnis hochladen.');
loadProjects();
} else {
alert('❌ Fehler: ' + (data.message || 'Projekt konnte nicht erstellt werden'));
}
} catch (error) {
alert('❌ Fehler beim Erstellen des Projekts');
}
});
// Load projects
async function loadProjects() {
const content = document.getElementById('projectsContent');
content.innerHTML = '<div class="loading">Projekte werden geladen...</div>';
try {
const response = await fetch('/api/admin/projects', {
headers: { 'Authorization': 'Bearer ' + token }
});
const projects = await response.json();
if (projects.length === 0) {
content.innerHTML = '<p>Keine Projekte vorhanden.</p>';
return;
}
let html = '<table class="project-table"><thead><tr><th>Projektname</th><th>Hochgeladen</th><th>Ablaufdatum</th><th>Passwort</th><th>Aktionen</th></tr></thead><tbody>';
projects.forEach((p, index) => {
const date = new Date(p.uploaddate).toLocaleString('de-DE');
const expiryDate = p.expiryDate ? new Date(p.expiryDate).toLocaleDateString('de-DE') : '-';
html += '<tr><td><strong>' + p.name + '</strong></td><td>' + date + '</td><td>' + expiryDate + '</td><td><code>' + p.password + '</code></td><td>';
html += '<button class="btn btn-primary btn-rename" data-id="' + p.id + '" data-name="' + p.name + '" title="Umbenennen">✏️</button> ';
html += '<button class="btn btn-primary btn-expiry" data-id="' + p.id + '" data-name="' + p.name + '" title="Ablaufdatum">📅</button> ';
html += '<button class="btn btn-copy btn-copy-pw" data-password="' + p.password + '" title="Passwort kopieren">📋 PW</button> ';
html += '<button class="btn btn-primary btn-change-pw" data-id="' + p.id + '" title="Passwort ändern">🔑</button> ';
html += '<button class="btn btn-warning btn-extract-rar" data-id="' + p.id + '" data-name="' + p.name + '" title="RAR entpacken">📦 RAR</button> ';
html += '<button class="btn btn-danger btn-delete" data-id="' + p.id + '" data-name="' + p.name + '" title="Löschen">🗑️</button>';
html += '</td></tr>';
});
html += '</tbody></table>';
content.innerHTML = html;
// Attach event listeners after DOM is updated
document.querySelectorAll('.btn-rename').forEach(btn => {
btn.addEventListener('click', () => renameProject(btn.dataset.id, btn.dataset.name));
});
document.querySelectorAll('.btn-expiry').forEach(btn => {
btn.addEventListener('click', () => setExpiryDate(btn.dataset.id, btn.dataset.name));
});
document.querySelectorAll('.btn-copy-pw').forEach(btn => {
btn.addEventListener('click', () => copyPassword(btn.dataset.password));
});
document.querySelectorAll('.btn-change-pw').forEach(btn => {
btn.addEventListener('click', () => changePassword(btn.dataset.id));
});
document.querySelectorAll('.btn-extract-rar').forEach(btn => {
btn.addEventListener('click', () => extractRarFiles(btn.dataset.id, btn.dataset.name));
});
document.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', () => deleteProject(btn.dataset.id, btn.dataset.name));
});
} catch (error) {
content.innerHTML = '<div class="error">Fehler beim Laden der Projekte</div>';
}
}
async function renameProject(id, oldName) {
const newName = prompt('Neuer Projektname:', oldName);
if (!newName || newName === oldName) return;
try {
const response = await fetch('/api/admin/projects/' + id + '/rename', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ newName })
});
if (response.ok) {
alert('✅ Projekt umbenannt!');
loadProjects();
if (document.getElementById('links-tab').classList.contains('active')) {
loadLinks();
}
} else {
alert('❌ Fehler beim Umbenennen');
}
} catch (error) {
alert('❌ Fehler beim Umbenennen');
}
}
async function setExpiryDate(id, name) {
const expiryDate = prompt('Ablaufdatum setzen (YYYY-MM-DD) - Leer lassen zum Entfernen:', '');
try {
const response = await fetch('/api/admin/projects/' + id + '/expiry', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ expiryDate: expiryDate || null })
});
if (response.ok) {
alert('✅ Ablaufdatum gesetzt!');
loadProjects();
} else {
alert('❌ Fehler beim Setzen des Ablaufdatums');
}
} catch (error) {
alert('❌ Fehler beim Setzen des Ablaufdatums');
}
}
function copyPassword(password) {
navigator.clipboard.writeText(password).then(() => {
alert('✅ Passwort in Zwischenablage kopiert!');
}).catch(() => {
alert('❌ Fehler beim Kopieren');
});
}
async function deleteProject(id, name) {
if (!confirm('Projekt "' + name + '" wirklich löschen?')) return;
try {
const response = await fetch('/api/admin/projects/' + id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
});
if (response.ok) {
alert('Projekt gelöscht!');
loadProjects();
} else {
alert('Fehler beim Löschen');
}
} catch (error) {
alert('Fehler beim Löschen');
}
}
async function changePassword(id) {
const newPassword = prompt('Neues Passwort eingeben:');
if (!newPassword) return;
try {
const response = await fetch('/api/admin/projects/' + id + '/password', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ newPassword })
});
if (response.ok) {
alert('Passwort geändert!');
loadProjects();
} else {
alert('Fehler beim Ändern');
}
} catch (error) {
alert('Fehler beim Ändern');
}
}
async function extractRarFiles(id, name) {
if (!confirm('RAR-Dateien im Projekt "' + name + '" entpacken?')) return;
try {
const response = await fetch('/api/admin/projects/' + id + '/extract-rar', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token
}
});
const data = await response.json();
if (response.ok) {
alert('✅ ' + data.message + '\\n\\nEntpackt: ' + data.extractedFiles + '\\nFehler: ' + data.failedFiles);
loadProjects();
} else {
alert('❌ Fehler: ' + (data.message || 'Entpacken fehlgeschlagen'));
}
} catch (error) {
alert('❌ Fehler beim Entpacken');
}
}
// Load links - KORRIGIERT: Keine Zeilenumbrüche in Strings mehr!
async function loadLinks() {
const content = document.getElementById('linksContent');
content.innerHTML = '<div class="loading">Links werden geladen...</div>';
try {
const response = await fetch('/api/admin/projects', {
headers: { 'Authorization': 'Bearer ' + token }
});
const projects = await response.json();
if (projects.length === 0) {
content.innerHTML = '<p>Keine Projekte vorhanden.</p>';
return;
}
let html = '';
projects.forEach((p, index) => {
const link = window.location.origin + '/' + p.shareid;
let expiryText = '';
let expiryDisplay = '';
if (p.expiryDate) {
const expiry = new Date(p.expiryDate).toLocaleDateString('de-DE');
expiryText = 'Das Projekt ist verfügbar bis: ' + expiry;
expiryDisplay = '<div style="margin-bottom: 15px; color: #ff6600;"><strong>⏰ Verfügbar bis:</strong> ' + expiry + '</div>';
}
// Erstelle Email-Text als Array - KORRIGIERT!
const emailParts = ['Sehr geehrte Damen und Herren,', '', 'Sie können das Projekt "' + p.name + '" unter folgendem Link aufrufen:', '', link, '', 'Passwort: ' + p.password];
if (expiryText) {
emailParts.push('');
emailParts.push(expiryText);
}
emailParts.push('');
emailParts.push('Mit freundlichen Grüßen');
const emailDisplay = emailParts.join('<br>');
html += '<div class="card" style="margin-bottom: 20px;">';
html += '<h3 style="color: #0066cc; margin-bottom: 15px;">' + p.name + '</h3>';
html += '<div style="margin-bottom: 10px;"><strong>Link:</strong> <span class="share-link">' + link + '</span></div>';
html += '<div style="margin-bottom: 15px;"><strong>Passwort:</strong> <code>' + p.password + '</code></div>';
html += expiryDisplay;
html += '<div style="display: flex; gap: 10px; flex-wrap: wrap;">';
html += '<button class="btn btn-copy link-copy-btn" data-index="' + index + '">📋 Link kopieren</button>';
html += '<button class="btn btn-copy pw-copy-btn" data-index="' + index + '">📋 Passwort kopieren</button>';
html += '<button class="btn btn-primary email-copy-btn" data-index="' + index + '">📧 Email-Text kopieren</button>';
html += '</div>';
html += '<div style="margin-top: 15px; padding: 15px; background: #f8f9fa; border-radius: 6px; white-space: pre-wrap; font-family: monospace; font-size: 0.9em;">' + emailDisplay + '</div>';
html += '</div>';
// Store data in window object
window['projectLink' + index] = link;
window['projectPw' + index] = p.password;
window['projectEmail' + index] = emailParts.join(String.fromCharCode(10));
});
content.innerHTML = html;
// Attach all event listeners
document.querySelectorAll('.link-copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = btn.dataset.index;
const link = window['projectLink' + idx];
navigator.clipboard.writeText(link).then(() => {
alert('✅ Link in Zwischenablage kopiert!');
}).catch(() => {
alert('❌ Fehler beim Kopieren');
});
});
});
document.querySelectorAll('.pw-copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = btn.dataset.index;
const pw = window['projectPw' + idx];
navigator.clipboard.writeText(pw).then(() => {
alert('✅ Passwort in Zwischenablage kopiert!');
}).catch(() => {
alert('❌ Fehler beim Kopieren');
});
});
});
document.querySelectorAll('.email-copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = btn.dataset.index;
const email = window['projectEmail' + idx];
navigator.clipboard.writeText(email).then(() => {
alert('✅ Email-Text in Zwischenablage kopiert!');
}).catch(() => {
alert('❌ Fehler beim Kopieren');
});
});
});
} catch (error) {
content.innerHTML = '<div class="error">Fehler beim Laden der Links</div>';
}
}
</script>
</body>
</html>`;
}
}

View File

@ -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<string>('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<string>('ADMIN_USERNAME');
const adminPassword = this.configService.get<string>('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 = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${name}</title>
</head>
<body>
<h1>Projekt: ${name}</h1>
<p>Dieses Projekt wurde manuell angelegt. Bitte laden Sie die Dateien per FTP hoch.</p>
<p>Pfad: ${projectDir}</p>
</body>
</html>`;
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<void> {
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<string[]> {
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<void>((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;
}
}

View File

@ -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();
}
}

View File

@ -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<string>('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<string>('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<string[]> {
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 <base> 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): <base href="/shareId/">
* - Für HTML in Unterordner (z.B. Web_0_web/pano.html): <base href="/shareId/Web_0_web/">
*
* @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 = `<base href="${basePath}">`;
// Entferne existierende <base> Tags
let cleanedContent = htmlContent.replace(/<base[^>]*>/gi, '');
// Try to inject after <head>
if (cleanedContent.includes('<head>')) {
return cleanedContent.replace('<head>', `<head>\n ${baseTag}`);
}
// Try to inject after <head with attributes
const headMatch = cleanedContent.match(/<head[^>]*>/i);
if (headMatch) {
return cleanedContent.replace(headMatch[0], `${headMatch[0]}\n ${baseTag}`);
}
// Fallback: inject after <html>
if (cleanedContent.includes('<html>')) {
return cleanedContent.replace('<html>', `<html>\n<head>\n ${baseTag}\n</head>`);
}
// Fallback: prepend to document
return `<!DOCTYPE html>\n<html>\n<head>\n ${baseTag}\n</head>\n<body>\n${cleanedContent}\n</body>\n</html>`;
}
async getProjectHtmlContent(projectName: string, htmlFile: string, shareId: string): Promise<string> {
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 <base> tag to fix relative paths - MIT HTML-Dateipfad!
htmlContent = this.injectBaseTag(htmlContent, shareId, htmlFile);
this.logger.log(`✅ Injected <base> 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<void> {
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<string, string> = {
'.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';
}
}

View File

@ -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<string>('UPLOAD_DIR') || '/var/www/pointcab_webexport_server/uploads/projects';
this.tempDir = this.configService.get<string>('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<any> {
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<string> {
// 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<string> {
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<void> {
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<void> {
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<string[]> {
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<string> {
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<void> {
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`);
}
}

View File

@ -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"
]
}

72
scripts/cleanup.sh Executable file
View File

@ -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"

80
scripts/db-check.sh Executable file
View File

@ -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}"

88
scripts/deploy.sh Executable file
View File

@ -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}"

97
scripts/install.sh Executable file
View File

@ -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 <<EOF
CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';
CREATE DATABASE $DB_NAME OWNER $DB_USER;
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
EOF
echo -e "\n${YELLOW}[4/8] Node.js installieren...${NC}"
curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
apt install -y nodejs
echo -e "\n${YELLOW}[5/8] PM2 installieren...${NC}"
npm install -g pm2
echo -e "\n${YELLOW}[6/8] Projekt einrichten...${NC}"
mkdir -p $INSTALL_DIR
cp -r nodejs_space $INSTALL_DIR/
mkdir -p $INSTALL_DIR/backups
mkdir -p $INSTALL_DIR/nodejs_space/uploads
# .env erstellen
cat > $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 ""