Initial commit
This commit is contained in:
commit
013eb78a7c
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
Binary file not shown.
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -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 ""
|
||||||
Loading…
Reference in New Issue