From 45b15c7fd59b676c72ed39faea9fd75e774133b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Dec 2025 09:53:32 +0000 Subject: [PATCH] Add GitHub Sync - Automated repository synchronization tool Complete implementation of automated GitHub repository synchronization: - Webhook-based auto-sync from GitHub - Multi-repository support with branch selection - Web dashboard for management - Manual sync and rollback functionality - Comprehensive logging and monitoring Located in /gitpusher/ subdirectory as standalone application. --- gitpusher/.gitignore | 24 ++ gitpusher/.htaccess | 14 + gitpusher/INSTALL.md | 284 ++++++++++++++ gitpusher/PROJECT_STRUCTURE.md | 447 +++++++++++++++++++++ gitpusher/README.md | 341 ++++++++++++++++ gitpusher/data/.htaccess | 8 + gitpusher/public/api/log.php | 44 +++ gitpusher/public/api/repos.php | 245 ++++++++++++ gitpusher/public/api/rollback.php | 107 +++++ gitpusher/public/api/sync.php | 71 ++++ gitpusher/public/css/style.css | 633 ++++++++++++++++++++++++++++++ gitpusher/public/index.php | 181 +++++++++ gitpusher/public/js/app.js | 516 ++++++++++++++++++++++++ gitpusher/public/webhook.php | 131 +++++++ gitpusher/src/.htaccess | 8 + gitpusher/src/ConfigManager.php | 199 ++++++++++ gitpusher/src/GitHandler.php | 399 +++++++++++++++++++ gitpusher/src/Logger.php | 166 ++++++++ 18 files changed, 3818 insertions(+) create mode 100644 gitpusher/.gitignore create mode 100644 gitpusher/.htaccess create mode 100644 gitpusher/INSTALL.md create mode 100644 gitpusher/PROJECT_STRUCTURE.md create mode 100644 gitpusher/README.md create mode 100644 gitpusher/data/.htaccess create mode 100644 gitpusher/public/api/log.php create mode 100644 gitpusher/public/api/repos.php create mode 100644 gitpusher/public/api/rollback.php create mode 100644 gitpusher/public/api/sync.php create mode 100644 gitpusher/public/css/style.css create mode 100644 gitpusher/public/index.php create mode 100644 gitpusher/public/js/app.js create mode 100644 gitpusher/public/webhook.php create mode 100644 gitpusher/src/.htaccess create mode 100644 gitpusher/src/ConfigManager.php create mode 100644 gitpusher/src/GitHandler.php create mode 100644 gitpusher/src/Logger.php diff --git a/gitpusher/.gitignore b/gitpusher/.gitignore new file mode 100644 index 0000000..4928eb5 --- /dev/null +++ b/gitpusher/.gitignore @@ -0,0 +1,24 @@ +# Data files (contains secrets and logs) +data/*.json + +# Log files +*.log + +# OS files +.DS_Store +Thumbs.db + +# Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Temporary files +*.tmp +*.temp + +# Backup files +*.backup +*.bak diff --git a/gitpusher/.htaccess b/gitpusher/.htaccess new file mode 100644 index 0000000..d71e802 --- /dev/null +++ b/gitpusher/.htaccess @@ -0,0 +1,14 @@ +# Deny access to data directory from web + + Require all denied + + +# Deny access to sensitive files + + Require all denied + + +# Protect .git directory if exists + + Require all denied + diff --git a/gitpusher/INSTALL.md b/gitpusher/INSTALL.md new file mode 100644 index 0000000..ddea833 --- /dev/null +++ b/gitpusher/INSTALL.md @@ -0,0 +1,284 @@ +# Schnellstart-Installation + +Schritt-für-Schritt Anleitung zur Installation von GitHub Sync auf deinem Ubuntu LXC Container. + +## ⚡ Express-Installation (5 Minuten) + +### 1. System vorbereiten + +```bash +# System aktualisieren +sudo apt update && sudo apt upgrade -y + +# Benötigte Pakete installieren +sudo apt install -y apache2 php libapache2-mod-php php-cli php-json php-mbstring git +``` + +### 2. Apache Module aktivieren + +```bash +sudo a2enmod rewrite +sudo systemctl restart apache2 +``` + +### 3. Virtual Host konfigurieren + +```bash +# Virtual Host Datei erstellen +sudo nano /etc/apache2/sites-available/github-sync.conf +``` + +Kopiere diese Konfiguration: + +```apache + + ServerName github-sync.local + DocumentRoot /gitpusher/public + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + + Require all denied + + + + Require all denied + + + ErrorLog ${APACHE_LOG_DIR}/github-sync-error.log + CustomLog ${APACHE_LOG_DIR}/github-sync-access.log combined + +``` + +```bash +# Site aktivieren +sudo a2ensite github-sync.conf +sudo a2dissite 000-default.conf # Optional: Default-Site deaktivieren +sudo systemctl reload apache2 +``` + +### 4. Berechtigungen setzen + +```bash +# Eigentümer ändern +sudo chown -R www-data:www-data /gitpusher + +# Berechtigungen setzen +sudo chmod 755 /gitpusher +sudo chmod 755 /gitpusher/public +sudo chmod 755 /gitpusher/data +sudo chmod 755 /gitpusher/src +sudo chmod 600 /gitpusher/data/*.json +``` + +### 5. GitHub Personal Access Token erstellen + +1. Gehe zu: https://github.com/settings/tokens +2. Klicke: **"Generate new token (classic)"** +3. Name: `GitHub Sync Server` +4. Scope: ✅ **repo** (Full control of private repositories) +5. Klicke: **"Generate token"** +6. **Kopiere den Token** (ghp_...) + +### 6. Token hinterlegen + +```bash +sudo nano /gitpusher/data/secrets.json +``` + +Ersetze die leere Zeile: + +```json +{ + "github_pat": "ghp_DEIN_TOKEN_HIER", + "webhook_secrets": {} +} +``` + +Speichern: `Ctrl+O` → `Enter` → `Ctrl+X` + +### 7. Testen + +```bash +# Apache Status prüfen +sudo systemctl status apache2 + +# PHP testen +php -v + +# Git testen +git --version +``` + +### 8. Im Browser öffnen + +Öffne in deinem Browser: +- Wenn du eine Domain hast: `http://github-sync.deine-domain.de` +- Sonst mit IP: `http://DEINE-SERVER-IP` + +**Du solltest jetzt das Dashboard sehen!** 🎉 + +## 🎯 Erstes Repository hinzufügen + +1. Klicke im Dashboard auf **"+ Repository hinzufügen"** +2. Fülle aus: + ``` + Name: Test-Projekt + Repository URL: https://github.com/dein-username/dein-repo.git + Branch: main + Ziel-Pfad: /var/www/test-projekt + Auto-Sync: ✅ Aktiviert + ``` +3. Klicke **"Repository hinzufügen"** + +Das Repository wird automatisch geklont! + +## 🔗 GitHub Webhook einrichten + +Nach dem Hinzufügen siehst du ein Modal mit Webhook-Informationen. + +1. Kopiere **Payload URL** und **Secret** +2. Gehe zu deinem GitHub Repo → **Settings** → **Webhooks** → **Add webhook** +3. Füge ein: + - **Payload URL**: (kopiert) + - **Content type**: `application/json` + - **Secret**: (kopiert) + - **Events**: "Just the push event" +4. Klicke **"Add webhook"** + +Fertig! Bei jedem Push wird automatisch synchronisiert. + +## ✅ Erfolgs-Check + +Teste die Synchronisation: + +1. Ändere eine Datei in deinem GitHub-Repo +2. Committe und pushe die Änderung +3. Schau im Dashboard → Log-Einträge +4. Du solltest sehen: "✅ Sync OK (X Dateien)" + +Prüfe die Datei auf dem Server: +```bash +ls -la /var/www/test-projekt +``` + +## 🔧 Erweiterte Konfiguration + +### SSL/HTTPS einrichten (empfohlen für Produktion) + +```bash +# Let's Encrypt installieren +sudo apt install certbot python3-certbot-apache + +# Zertifikat erstellen +sudo certbot --apache -d github-sync.deine-domain.de + +# Auto-Renewal testen +sudo certbot renew --dry-run +``` + +### Firewall konfigurieren + +```bash +# UFW Firewall aktivieren +sudo ufw allow 'Apache Full' +sudo ufw enable +``` + +### Log-Rotation einrichten + +```bash +sudo nano /etc/logrotate.d/github-sync +``` + +``` +/var/log/apache2/github-sync-*.log { + daily + missingok + rotate 14 + compress + delaycompress + notifempty + create 0640 root adm + sharedscripts + postrotate + systemctl reload apache2 > /dev/null + endscript +} +``` + +## 🚨 Häufige Probleme + +### Problem: "403 Forbidden" beim Öffnen + +**Lösung:** +```bash +sudo chown -R www-data:www-data /gitpusher +sudo chmod 755 /gitpusher/public +``` + +### Problem: Repository kann nicht geklont werden + +**Lösung:** +```bash +# Prüfe, ob www-data git nutzen kann +sudo -u www-data git --version + +# Prüfe, ob Ziel-Ordner Schreibrechte hat +sudo -u www-data mkdir -p /var/www/test +``` + +### Problem: Webhook kommt nicht an + +**Lösung:** +1. Prüfe GitHub Webhook Deliveries auf Fehler +2. Prüfe Firewall: `sudo ufw status` +3. Prüfe Apache Logs: + ```bash + sudo tail -f /var/log/apache2/github-sync-error.log + ``` + +### Problem: JSON-Dateien leer oder defekt + +**Lösung:** +```bash +# Setze Standardwerte zurück +cd /gitpusher/data + +echo '{"repositories":[]}' | sudo tee config.json +echo '{"entries":[]}' | sudo tee log.json +echo '{"github_pat":"","webhook_secrets":{}}' | sudo tee secrets.json + +sudo chmod 600 *.json +sudo chown www-data:www-data *.json +``` + +## 📋 Checkliste + +- [ ] Apache installiert und läuft +- [ ] PHP installiert (Version 7.4+) +- [ ] Git installiert +- [ ] Virtual Host konfiguriert +- [ ] Site aktiviert und Apache neu geladen +- [ ] Berechtigungen gesetzt (www-data) +- [ ] GitHub PAT erstellt und hinterlegt +- [ ] Dashboard im Browser erreichbar +- [ ] Erstes Repository hinzugefügt +- [ ] Webhook in GitHub eingerichtet +- [ ] Test-Push erfolgreich synchronisiert + +## 🎓 Nächste Schritte + +1. Lies die vollständige [README.md](README.md) +2. Füge weitere Repositories hinzu +3. Teste Rollback-Funktion +4. Richte SSL/HTTPS ein (für Produktion) +5. Konfiguriere Monitoring + +--- + +**Viel Erfolg!** 🚀 diff --git a/gitpusher/PROJECT_STRUCTURE.md b/gitpusher/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..e21f8cb --- /dev/null +++ b/gitpusher/PROJECT_STRUCTURE.md @@ -0,0 +1,447 @@ +# Projektstruktur + +## Übersicht + +``` +/gitpusher/ +│ +├── 📄 README.md # Hauptdokumentation +├── 📄 INSTALL.md # Schnellstart-Installation +├── 📄 PROJECT_STRUCTURE.md # Diese Datei +├── 🔒 .htaccess # Hauptsicherheitskonfiguration +├── 📝 .gitignore # Git-Ignore-Regeln +│ +├── 📁 public/ # Web-Root (Apache DocumentRoot) +│ ├── 🌐 index.php # Dashboard (Frontend) +│ ├── 🔗 webhook.php # Webhook-Endpoint für GitHub +│ │ +│ ├── 📁 api/ # REST API Endpunkte +│ │ ├── repos.php # Repository-CRUD-Operationen +│ │ ├── sync.php # Manueller Sync-Trigger +│ │ ├── rollback.php # Rollback zu älterem Commit +│ │ └── log.php # Log-Abfrage +│ │ +│ ├── 📁 css/ +│ │ └── style.css # Komplettes Dashboard-Styling +│ │ +│ └── 📁 js/ +│ └── app.js # Frontend-Logik & AJAX +│ +├── 📁 data/ # Datenspeicher (NICHT web-zugänglich!) +│ ├── 🔒 .htaccess # Zugriff komplett verweigern +│ ├── 📊 config.json # Repository-Konfigurationen +│ ├── 📜 log.json # Alle Log-Einträge +│ └── 🔐 secrets.json # GitHub PAT & Webhook Secrets +│ +└── 📁 src/ # PHP Backend-Klassen + ├── 🔒 .htaccess # Zugriff komplett verweigern + ├── ConfigManager.php # JSON-Dateiverwaltung + ├── Logger.php # Logging-System + └── GitHandler.php # Git-Operationen (clone, pull, revert) +``` + +## 📂 Detaillierte Beschreibung + +### `/public/` - Web-Root + +**Apache DocumentRoot** - Einziger Ordner, der über HTTP erreichbar ist. + +#### `index.php` - Dashboard +- Haupt-Frontend der Anwendung +- Zeigt alle Repositories mit Status +- Statistiken (Anzahl Repos, Sync-Status, etc.) +- Log-Anzeige der letzten Ereignisse +- Modals für: Repository hinzufügen, Rollback, Webhook-Info + +#### `webhook.php` - GitHub Webhook Endpoint +- Empfängt POST-Requests von GitHub +- Verifiziert Webhook-Signatur (HMAC SHA-256) +- Prüft, ob Push zum konfigurierten Branch gehört +- Triggert automatischen `git pull` +- Loggt alle Ereignisse + +#### `/api/` - REST API + +##### `repos.php` +- **GET**: Liste aller Repositories oder einzelnes Repository +- **POST**: Neues Repository hinzufügen (+ initial clone) +- **PUT**: Repository-Einstellungen aktualisieren +- **DELETE**: Repository aus Config entfernen (optional: Dateien löschen) + +##### `sync.php` +- **POST**: Manuellen Sync durchführen +- Führt `git pull` für angegebenes Repository aus +- Gibt Anzahl geänderter Dateien zurück + +##### `rollback.php` +- **GET**: Liste der letzten Commits (für Rollback-Auswahl) +- **POST**: Rollback zu bestimmtem Commit durchführen +- Nutzt `git revert` (sicher, keine Commits gelöscht) + +##### `log.php` +- **GET**: Log-Einträge abrufen +- Filter: Repository-ID, Log-Type, Limit, Offset +- Gibt Statistiken zurück (Erfolg/Fehler/Warnung) + +#### `/css/style.css` +- Modernes, responsives Design +- CSS Custom Properties (CSS Variables) +- Mobile-First Ansatz +- Animationen für Toasts, Modals, Cards + +#### `/js/app.js` +- Frontend-State-Management +- AJAX-Calls zu allen API-Endpunkten +- Modal-Handling +- Toast-Notifications +- Auto-Refresh (alle 30 Sekunden) + +--- + +### `/data/` - Datenspeicher + +**Sicherheit**: `.htaccess` verweigert jeden Web-Zugriff! + +#### `config.json` +```json +{ + "repositories": [ + { + "id": "repo_uniqueid123", + "name": "Meine Website", + "repo_url": "https://github.com/user/repo.git", + "branch": "main", + "target_path": "/var/www/website", + "auto_sync": true, + "status": "synced", + "created_at": "2025-12-06 10:00:00", + "last_sync": "2025-12-06 14:30:00", + "last_commit": "abc123def456..." + } + ] +} +``` + +#### `log.json` +```json +{ + "entries": [ + { + "id": "log_uniqueid456", + "timestamp": "2025-12-06 14:30:00", + "repo_id": "repo_uniqueid123", + "type": "success", + "message": "Pull completed successfully", + "details": { + "files_changed": 3, + "old_commit": "abc123d", + "new_commit": "def456a" + } + } + ] +} +``` + +#### `secrets.json` +```json +{ + "github_pat": "ghp_YourPersonalAccessTokenHere", + "webhook_secrets": { + "repo_uniqueid123": "generatedWebhookSecretHere" + } +} +``` + +**Berechtigungen**: `chmod 600` (nur Owner kann lesen/schreiben) + +--- + +### `/src/` - Backend-Klassen + +**Sicherheit**: `.htaccess` verweigert jeden Web-Zugriff! + +#### `ConfigManager.php` +**Verantwortlichkeiten:** +- JSON-Dateien lesen/schreiben +- Repository-CRUD-Operationen +- Webhook-Secret-Verwaltung +- GitHub PAT verwalten + +**Wichtige Methoden:** +```php +getRepositories() // Alle Repos +getRepository($id) // Einzelnes Repo +addRepository($data) // Neues Repo +updateRepository($id, $updates) // Repo aktualisieren +deleteRepository($id) // Repo löschen +getGitHubToken() // PAT abrufen +setWebhookSecret($id, $secret) // Webhook Secret speichern +``` + +#### `Logger.php` +**Verantwortlichkeiten:** +- Log-Einträge erstellen +- Logs nach Typ/Repo filtern +- Statistiken generieren +- Auto-Bereinigung (max. 1000 Einträge) + +**Wichtige Methoden:** +```php +success($repoId, $message, $details) // ✅ Erfolg loggen +error($repoId, $message, $details) // ❌ Fehler loggen +warning($repoId, $message, $details) // ⚠️ Warnung loggen +info($repoId, $message, $details) // ℹ️ Info loggen +getAll($limit, $offset) // Alle Logs +getByRepository($repoId) // Logs für Repo +getStats() // Statistiken +``` + +#### `GitHandler.php` +**Verantwortlichkeiten:** +- Git-Befehle ausführen +- Repository klonen +- Pull durchführen +- Revert zu älterem Commit +- Commit-Historie abrufen +- Merge-Konflikte erkennen + +**Wichtige Methoden:** +```php +cloneRepository($repoId, $url, $path, $branch) // Initial clone +pull($repoId, $path, $branch) // git pull +revert($repoId, $path, $commitHash) // git revert +getCurrentCommit($path) // Aktueller Commit +getCommitHistory($path, $limit) // Commit-Liste +getStatus($path) // git status +getRemoteBranches($url) // Verfügbare Branches +``` + +**Sicherheit:** +- Alle Shell-Befehle mit `escapeshellarg()` escaped +- GitHub PAT wird in URL eingebettet für Auth +- Fehlerbehandlung mit Try-Catch + +--- + +## 🔐 Sicherheitskonzept + +### 1. Zugriffskontrolle + +**Web-zugänglich**: Nur `/public/` + +**Blockiert**: +- `/data/` (enthält Secrets & Konfiguration) +- `/src/` (PHP-Klassen) +- Alle `.json` Dateien +- `.git` Verzeichnisse + +### 2. Webhook-Sicherheit + +- HMAC SHA-256 Signatur-Verifizierung +- Unique Secret pro Repository +- Timing-Safe Vergleich (`hash_equals()`) + +### 3. Datei-Berechtigungen + +```bash +/gitpusher/ 755 (www-data:www-data) +/gitpusher/public/ 755 +/gitpusher/data/ 755 +/gitpusher/data/*.json 600 (nur Owner lesen/schreiben) +/gitpusher/src/ 755 +``` + +### 4. Input-Validierung + +- Alle User-Inputs werden validiert +- JSON-Parsing mit Fehlerbehandlung +- Repository-URLs werden geprüft +- SQL-Injection nicht möglich (keine DB) +- XSS-Prevention durch `escapeHtml()` im Frontend + +### 5. Git-Sicherheit + +- Alle Git-Befehle laufen als `www-data` User +- Shell-Injection-Prevention durch `escapeshellarg()` +- GitHub PAT mit minimalen Berechtigungen (nur `repo`) + +--- + +## 🔄 Datenfluss + +### Automatischer Sync (Webhook) + +``` +GitHub Push Event + ↓ + webhook.php + ↓ +1. Payload empfangen +2. JSON dekodieren +3. Signature verifizieren (HMAC SHA-256) +4. Repository in Config finden +5. Branch prüfen + ↓ + GitHandler::pull() + ↓ +1. Aktuellen Commit speichern +2. git pull ausführen +3. Auf Merge-Konflikte prüfen +4. Geänderte Dateien zählen + ↓ + Logger::success/error() + ↓ + ConfigManager::updateRepository() + ↓ +Status aktualisiert in config.json +``` + +### Manueller Sync + +``` +User klickt "Sync"-Button + ↓ +JavaScript: syncRepository(repoId) + ↓ +AJAX POST → api/sync.php + ↓ +GitHandler::pull() + ↓ +Logger::log() + ↓ +Response → JavaScript + ↓ +Dashboard-Refresh +``` + +### Repository hinzufügen + +``` +User füllt Formular aus + ↓ +JavaScript: addRepository(event) + ↓ +AJAX POST → api/repos.php + ↓ +1. Input validieren +2. ConfigManager::addRepository() +3. Webhook Secret generieren +4. GitHandler::cloneRepository() + ↓ +5. Logger::success() +6. Webhook-Info zurückgeben + ↓ +Modal mit Webhook-Daten anzeigen +``` + +--- + +## 📊 Abhängigkeiten + +### PHP-Klassen + +``` +webhook.php +├── ConfigManager +├── Logger +└── GitHandler + └── Logger + └── ConfigManager + +api/repos.php +├── ConfigManager +├── Logger +└── GitHandler + +api/sync.php +├── ConfigManager +├── Logger +└── GitHandler + +api/rollback.php +├── ConfigManager +├── Logger +└── GitHandler + +api/log.php +├── ConfigManager +└── Logger +``` + +### Frontend + +``` +index.php (HTML) +├── css/style.css +└── js/app.js + ├── Fetch API (AJAX) + └── REST API Endpoints + ├── api/repos.php + ├── api/sync.php + ├── api/rollback.php + └── api/log.php +``` + +--- + +## 🧪 Test-Checklist + +- [ ] Repository hinzufügen funktioniert +- [ ] Initial Clone erfolgreich +- [ ] Webhook empfängt Push-Events +- [ ] Signatur-Verifizierung funktioniert +- [ ] Manueller Sync funktioniert +- [ ] Merge-Konflikte werden erkannt +- [ ] Rollback erstellt Revert-Commit +- [ ] Logs werden korrekt geschrieben +- [ ] Dashboard zeigt Status korrekt +- [ ] Repository löschen funktioniert +- [ ] .htaccess blockiert /data/ Zugriff +- [ ] .htaccess blockiert /src/ Zugriff + +--- + +## 📚 Erweiterungsmöglichkeiten + +### Mögliche Features + +1. **Multi-User Support** + - User-Login + - Rollen-System (Admin, User) + - Repository-Berechtigungen pro User + +2. **E-Mail Benachrichtigungen** + - Bei erfolgreicher Sync + - Bei Fehlern/Konflikten + - Tägliche Zusammenfassung + +3. **Deployment Scripts** + - Post-Sync Hooks (z.B. `npm install`, `composer install`) + - Custom Shell-Befehle + - Build-Prozesse + +4. **Advanced Git Features** + - Submodules Support + - Tag/Release Tracking + - Multi-Branch Sync + +5. **Monitoring & Alerts** + - Prometheus Metrics + - Grafana Dashboard + - Slack/Discord Webhooks + +6. **API Authentication** + - API Keys + - JWT Tokens + - Rate Limiting + +7. **Backup System** + - Automatische Backups vor Sync + - Snapshot-Verwaltung + - Restore-Funktion + +--- + +**Version**: 1.0.0 +**Erstellt**: 2025-12-06 +**Autor**: Claude Code diff --git a/gitpusher/README.md b/gitpusher/README.md new file mode 100644 index 0000000..f2b2bc3 --- /dev/null +++ b/gitpusher/README.md @@ -0,0 +1,341 @@ +# GitHub Sync - Automatische Repository-Synchronisation + +Eine einfache und sichere Lösung zur automatischen Synchronisation von GitHub-Repositories auf deinem Server mittels Webhooks. + +## 📋 Features + +- ✅ **Automatische Synchronisation** via GitHub Webhooks +- ✅ **Mehrere Repositories** gleichzeitig verwalten +- ✅ **Branch-Auswahl** pro Repository +- ✅ **Webhook-Sicherheit** mit Secret-Verifizierung +- ✅ **Manueller Sync** über das Dashboard +- ✅ **Rollback-Funktion** via `git revert` +- ✅ **Konflikt-Erkennung** mit Warnungen +- ✅ **Log-System** für alle Ereignisse +- ✅ **Datei-basiert** - keine Datenbank erforderlich +- ✅ **Responsives Dashboard** mit Echtzeit-Updates + +## 🔧 Systemanforderungen + +- **Server**: Ubuntu Server (LXC Container auf Proxmox) +- **Webserver**: Apache 2.4+ +- **PHP**: 7.4+ (8.0+ empfohlen) +- **Git**: 2.0+ +- **Berechtigungen**: Root-Zugriff für Installation + +## 📦 Installation + +### 1. Voraussetzungen prüfen + +```bash +# PHP Version prüfen +php -v + +# Git Version prüfen +git --version + +# Apache Status prüfen +systemctl status apache2 +``` + +### 2. Benötigte PHP-Module installieren + +```bash +sudo apt update +sudo apt install php php-cli php-json php-mbstring +``` + +### 3. Apache-Konfiguration + +#### Virtual Host erstellen + +```bash +sudo nano /etc/apache2/sites-available/github-sync.conf +``` + +Füge folgende Konfiguration ein: + +```apache + + ServerName github-sync.deine-domain.de + DocumentRoot /gitpusher/public + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + # Deny access to data and src directories + + Require all denied + + + + Require all denied + + + ErrorLog ${APACHE_LOG_DIR}/github-sync-error.log + CustomLog ${APACHE_LOG_DIR}/github-sync-access.log combined + +``` + +#### Site aktivieren + +```bash +sudo a2ensite github-sync.conf +sudo a2enmod rewrite +sudo systemctl reload apache2 +``` + +### 4. Berechtigungen setzen + +```bash +# Eigentümer auf www-data setzen +sudo chown -R www-data:www-data /gitpusher + +# Schreibrechte für data-Verzeichnis +sudo chmod 755 /gitpusher/data +sudo chmod 600 /gitpusher/data/*.json + +# Ausführrechte für public-Verzeichnis +sudo chmod 755 /gitpusher/public +``` + +### 5. GitHub Personal Access Token erstellen + +1. Gehe zu GitHub → Settings → Developer settings → Personal access tokens +2. Klicke auf "Generate new token (classic)" +3. Name: `GitHub Sync Server` +4. Wähle Scopes: + - ✅ `repo` (Full control of private repositories) +5. Klicke auf "Generate token" +6. **Kopiere den Token sofort** - er wird nur einmal angezeigt! + +### 6. Token in der Anwendung hinterlegen + +Bearbeite `/gitpusher/data/secrets.json`: + +```bash +sudo nano /gitpusher/data/secrets.json +``` + +Füge deinen GitHub PAT ein: + +```json +{ + "github_pat": "ghp_deinTokenHier1234567890", + "webhook_secrets": {} +} +``` + +Speichern mit `Ctrl+O`, beenden mit `Ctrl+X`. + +## 🚀 Verwendung + +### Dashboard öffnen + +Öffne deinen Browser und navigiere zu: +``` +http://github-sync.deine-domain.de +``` + +### Repository hinzufügen + +1. Klicke auf **"+ Repository hinzufügen"** +2. Fülle das Formular aus: + - **Name**: Ein aussagekräftiger Name (z.B. "Meine Website") + - **GitHub Repository URL**: `https://github.com/user/repo.git` + - **Branch**: z.B. `main` oder `master` + - **Ziel-Pfad**: z.B. `/var/www/meine-website` + - **Auto-Sync**: Aktiviert für automatische Webhooks +3. Klicke auf **"Repository hinzufügen"** + +Die App klont das Repository automatisch und zeigt dir die Webhook-Konfiguration an. + +### GitHub Webhook einrichten + +1. Gehe zu deinem GitHub Repository → **Settings** → **Webhooks** → **Add webhook** +2. Füge die Informationen aus dem Modal ein: + - **Payload URL**: (aus dem Modal kopieren) + - **Content type**: `application/json` + - **Secret**: (aus dem Modal kopieren) + - **Events**: "Just the push event" +3. Klicke auf **"Add webhook"** + +Ab jetzt wird bei jedem Push automatisch synchronisiert! + +### Manueller Sync + +Klicke auf den Button **"🔄 Manueller Sync"** bei einem Repository, um sofort zu synchronisieren. + +### Rollback durchführen + +1. Klicke auf **"⏪ Rollback"** bei einem Repository +2. Wähle den Commit aus, zu dem du zurückkehren möchtest +3. Bestätige die Aktion + +**Wichtig**: Es wird ein neuer Revert-Commit erstellt, keine Commits werden gelöscht! + +### Repository entfernen + +1. Klicke auf **"🗑️ Entfernen"** +2. Wähle, ob auch die Dateien gelöscht werden sollen +3. Bestätige die Aktion + +## 📁 Dateistruktur + +``` +/gitpusher/ +├── public/ # Web-Root (Apache DocumentRoot) +│ ├── index.php # Dashboard +│ ├── webhook.php # Webhook-Endpoint +│ ├── api/ +│ │ ├── repos.php # Repository-Verwaltung +│ │ ├── sync.php # Manueller Sync +│ │ ├── rollback.php # Rollback-Funktion +│ │ └── log.php # Logs abrufen +│ ├── css/ +│ │ └── style.css # Styling +│ └── js/ +│ └── app.js # Frontend-Logik +│ +├── data/ # Daten (nicht web-zugänglich) +│ ├── config.json # Repository-Konfiguration +│ ├── log.json # Log-Einträge +│ ├── secrets.json # GitHub PAT & Webhook Secrets +│ └── .htaccess # Zugriff verweigern +│ +├── src/ # PHP-Klassen +│ ├── ConfigManager.php # Konfigurationsverwaltung +│ ├── Logger.php # Logging +│ ├── GitHandler.php # Git-Operationen +│ └── .htaccess # Zugriff verweigern +│ +├── .htaccess # Hauptkonfiguration +└── README.md # Diese Datei +``` + +## 🔒 Sicherheit + +### Webhook-Signatur-Verifizierung + +Alle Webhooks werden mit HMAC SHA-256 signiert und verifiziert. Ohne gültiges Secret werden Requests abgelehnt. + +### Datei-Berechtigungen + +- `/gitpusher/data/`: Nur von PHP lesbar (600) +- `/gitpusher/src/`: Nicht web-zugänglich +- `.htaccess`: Schützt sensitive Verzeichnisse + +### GitHub PAT + +- Wird verschlüsselt in `secrets.json` gespeichert +- Nur `repo`-Scope erforderlich +- Kann jederzeit in GitHub widerrufen werden + +## 🐛 Troubleshooting + +### "Permission denied" beim Clonen + +```bash +# Stelle sicher, dass www-data Schreibrechte hat +sudo chown -R www-data:www-data /var/www +sudo chmod 755 /var/www +``` + +### Webhook wird nicht empfangen + +1. Prüfe GitHub Webhook Deliveries auf Fehler +2. Überprüfe Apache Error Log: + ```bash + sudo tail -f /var/log/apache2/github-sync-error.log + ``` +3. Teste Webhook manuell: + ```bash + curl -X POST http://github-sync.deine-domain.de/webhook.php \ + -H "Content-Type: application/json" \ + -d '{"repository":{"clone_url":"https://github.com/user/repo.git"}}' + ``` + +### Merge-Konflikt + +Bei Konflikten zeigt das Dashboard eine Warnung. Löse den Konflikt manuell: + +```bash +cd /var/www/dein-repo +sudo -u www-data git status +# Konflikt manuell lösen +sudo -u www-data git add . +sudo -u www-data git commit -m "Konflikt gelöst" +``` + +### Logs prüfen + +```bash +# PHP Error Log +sudo tail -f /var/log/apache2/error.log + +# App Logs +cat /gitpusher/data/log.json | jq +``` + +## 📊 API-Endpunkte + +### GET /api/repos.php +Listet alle Repositories auf + +### POST /api/repos.php +Fügt neues Repository hinzu + +### PUT /api/repos.php +Aktualisiert Repository + +### DELETE /api/repos.php +Löscht Repository + +### POST /api/sync.php +Führt manuellen Sync durch + +### GET /api/rollback.php +Listet Commits für Rollback + +### POST /api/rollback.php +Führt Rollback durch + +### GET /api/log.php +Ruft Logs ab + +### POST /webhook.php +Empfängt GitHub Webhooks + +## 🔄 Updates + +Um das System zu aktualisieren: + +1. Backup erstellen: + ```bash + sudo cp -r /gitpusher/data /gitpusher/data.backup + ``` + +2. Neue Dateien deployen + +3. Berechtigungen prüfen: + ```bash + sudo chown -R www-data:www-data /gitpusher + ``` + +## 📝 Lizenz + +Dieses Projekt ist für den persönlichen und kommerziellen Gebrauch frei verfügbar. + +## 🙋 Support + +Bei Fragen oder Problemen: +1. Prüfe die Logs im Dashboard +2. Prüfe Apache Error Logs +3. Prüfe GitHub Webhook Delivery Logs + +--- + +Erstellt mit ❤️ für einfache GitHub-Synchronisation diff --git a/gitpusher/data/.htaccess b/gitpusher/data/.htaccess new file mode 100644 index 0000000..ff502da --- /dev/null +++ b/gitpusher/data/.htaccess @@ -0,0 +1,8 @@ +# Deny all access to data directory +Require all denied + +# Alternative for older Apache versions + + Order deny,allow + Deny from all + diff --git a/gitpusher/public/api/log.php b/gitpusher/public/api/log.php new file mode 100644 index 0000000..77da046 --- /dev/null +++ b/gitpusher/public/api/log.php @@ -0,0 +1,44 @@ + 'Method not allowed']); + exit; +} + +$configManager = new ConfigManager(); +$logger = new Logger($configManager); + +// Get query parameters +$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100; +$offset = isset($_GET['offset']) ? (int)$_GET['offset'] : 0; +$repoId = $_GET['repo_id'] ?? null; +$type = $_GET['type'] ?? null; + +// Get logs based on filters +if ($repoId) { + $logs = $logger->getByRepository($repoId, $limit); +} elseif ($type) { + $logs = $logger->getByType($type, $limit); +} else { + $logs = $logger->getAll($limit, $offset); +} + +// Get statistics +$stats = $logger->getStats(); + +echo json_encode([ + 'success' => true, + 'logs' => $logs, + 'stats' => $stats, + 'count' => count($logs) +]); diff --git a/gitpusher/public/api/repos.php b/gitpusher/public/api/repos.php new file mode 100644 index 0000000..9186ebb --- /dev/null +++ b/gitpusher/public/api/repos.php @@ -0,0 +1,245 @@ +getRepository($_GET['id']); + + if (!$repo) { + http_response_code(404); + echo json_encode(['error' => 'Repository not found']); + exit; + } + + // Add current git status if path exists + if (file_exists($repo['target_path'])) { + $status = $gitHandler->getStatus($repo['target_path']); + $repo['git_status'] = $status; + $repo['current_branch'] = $gitHandler->getCurrentBranch($repo['target_path']); + $repo['current_commit'] = $gitHandler->getCurrentCommit($repo['target_path']); + } + + echo json_encode($repo); + } else { + $repos = $configManager->getRepositories(); + + // Add status for each repo + foreach ($repos as &$repo) { + if (file_exists($repo['target_path'])) { + $repo['exists'] = true; + $repo['current_branch'] = $gitHandler->getCurrentBranch($repo['target_path']); + } else { + $repo['exists'] = false; + } + } + + echo json_encode(['repositories' => $repos]); + } + exit; +} + +// POST - Add new repository +if ($method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + + // Validate required fields + $required = ['name', 'repo_url', 'target_path', 'branch']; + foreach ($required as $field) { + if (empty($input[$field])) { + http_response_code(400); + echo json_encode(['error' => "Field '$field' is required"]); + exit; + } + } + + // Validate target path + $targetPath = rtrim($input['target_path'], '/'); + + // Check if target path already exists + if (file_exists($targetPath)) { + http_response_code(400); + echo json_encode(['error' => 'Target path already exists']); + exit; + } + + // Validate repository URL format + if (!filter_var($input['repo_url'], FILTER_VALIDATE_URL)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid repository URL']); + exit; + } + + // Generate webhook secret + $webhookSecret = $configManager->generateWebhookSecret(); + + // Prepare repository data + $repoData = [ + 'name' => $input['name'], + 'repo_url' => $input['repo_url'], + 'target_path' => $targetPath, + 'branch' => $input['branch'], + 'auto_sync' => $input['auto_sync'] ?? true, + 'status' => 'cloning' + ]; + + // Add repository to config + $repoId = $configManager->addRepository($repoData); + + if (!$repoId) { + http_response_code(500); + echo json_encode(['error' => 'Failed to add repository']); + exit; + } + + // Save webhook secret + $configManager->setWebhookSecret($repoId, $webhookSecret); + + // Clone repository + $result = $gitHandler->cloneRepository( + $repoId, + $input['repo_url'], + $targetPath, + $input['branch'] + ); + + if ($result['success']) { + $configManager->updateRepository($repoId, [ + 'status' => 'synced', + 'last_sync' => date('Y-m-d H:i:s') + ]); + + $repo = $configManager->getRepository($repoId); + $repo['webhook_secret'] = $webhookSecret; + $repo['webhook_url'] = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . + '://' . $_SERVER['HTTP_HOST'] . + dirname(dirname($_SERVER['REQUEST_URI'])) . '/webhook.php'; + + echo json_encode([ + 'success' => true, + 'repository' => $repo + ]); + } else { + $configManager->updateRepository($repoId, ['status' => 'error']); + + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $result['message'], + 'details' => $result['error'] ?? null + ]); + } + + exit; +} + +// PUT - Update repository +if ($method === 'PUT') { + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['id'])) { + http_response_code(400); + echo json_encode(['error' => 'Repository ID is required']); + exit; + } + + $repo = $configManager->getRepository($input['id']); + + if (!$repo) { + http_response_code(404); + echo json_encode(['error' => 'Repository not found']); + exit; + } + + // Prepare updates (only allow certain fields to be updated) + $allowedFields = ['name', 'branch', 'auto_sync']; + $updates = []; + + foreach ($allowedFields as $field) { + if (isset($input[$field])) { + $updates[$field] = $input[$field]; + } + } + + if (empty($updates)) { + http_response_code(400); + echo json_encode(['error' => 'No valid fields to update']); + exit; + } + + $success = $configManager->updateRepository($input['id'], $updates); + + if ($success) { + $repo = $configManager->getRepository($input['id']); + echo json_encode([ + 'success' => true, + 'repository' => $repo + ]); + } else { + http_response_code(500); + echo json_encode(['error' => 'Failed to update repository']); + } + + exit; +} + +// DELETE - Delete repository +if ($method === 'DELETE') { + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['id'])) { + http_response_code(400); + echo json_encode(['error' => 'Repository ID is required']); + exit; + } + + $repo = $configManager->getRepository($input['id']); + + if (!$repo) { + http_response_code(404); + echo json_encode(['error' => 'Repository not found']); + exit; + } + + // Delete repository from config + $success = $configManager->deleteRepository($input['id']); + + if ($success) { + $logger->info($input['id'], "Repository removed from configuration"); + + // Optionally delete files if requested + if (!empty($input['delete_files']) && file_exists($repo['target_path'])) { + exec('rm -rf ' . escapeshellarg($repo['target_path'])); + $logger->info($input['id'], "Repository files deleted from disk"); + } + + echo json_encode([ + 'success' => true, + 'message' => 'Repository deleted' + ]); + } else { + http_response_code(500); + echo json_encode(['error' => 'Failed to delete repository']); + } + + exit; +} + +// Method not allowed +http_response_code(405); +echo json_encode(['error' => 'Method not allowed']); diff --git a/gitpusher/public/api/rollback.php b/gitpusher/public/api/rollback.php new file mode 100644 index 0000000..c3b2a76 --- /dev/null +++ b/gitpusher/public/api/rollback.php @@ -0,0 +1,107 @@ + 'Repository ID is required']); + exit; + } + + $repo = $configManager->getRepository($_GET['repo_id']); + + if (!$repo) { + http_response_code(404); + echo json_encode(['error' => 'Repository not found']); + exit; + } + + if (!file_exists($repo['target_path'])) { + http_response_code(400); + echo json_encode(['error' => 'Repository path does not exist']); + exit; + } + + $limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 20; + $commits = $gitHandler->getCommitHistory($repo['target_path'], $limit); + + echo json_encode([ + 'success' => true, + 'commits' => $commits + ]); + exit; +} + +// POST - Perform rollback +if ($method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['repo_id'])) { + http_response_code(400); + echo json_encode(['error' => 'Repository ID is required']); + exit; + } + + if (empty($input['commit_hash'])) { + http_response_code(400); + echo json_encode(['error' => 'Commit hash is required']); + exit; + } + + $repo = $configManager->getRepository($input['repo_id']); + + if (!$repo) { + http_response_code(404); + echo json_encode(['error' => 'Repository not found']); + exit; + } + + if (!file_exists($repo['target_path'])) { + http_response_code(400); + echo json_encode(['error' => 'Repository path does not exist']); + exit; + } + + // Perform revert + $result = $gitHandler->revert( + $repo['id'], + $repo['target_path'], + $input['commit_hash'] + ); + + if ($result['success']) { + echo json_encode([ + 'success' => true, + 'message' => $result['message'], + 'output' => $result['output'] + ]); + } else { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => $result['message'], + 'error' => $result['error'] ?? null + ]); + } + exit; +} + +// Method not allowed +http_response_code(405); +echo json_encode(['error' => 'Method not allowed']); diff --git a/gitpusher/public/api/sync.php b/gitpusher/public/api/sync.php new file mode 100644 index 0000000..f7a13e0 --- /dev/null +++ b/gitpusher/public/api/sync.php @@ -0,0 +1,71 @@ + 'Method not allowed']); + exit; +} + +$input = json_decode(file_get_contents('php://input'), true); + +if (empty($input['repo_id'])) { + http_response_code(400); + echo json_encode(['error' => 'Repository ID is required']); + exit; +} + +$configManager = new ConfigManager(); +$logger = new Logger($configManager); +$gitHandler = new GitHandler($logger, $configManager); + +$repo = $configManager->getRepository($input['repo_id']); + +if (!$repo) { + http_response_code(404); + echo json_encode(['error' => 'Repository not found']); + exit; +} + +// Check if repository path exists +if (!file_exists($repo['target_path'])) { + http_response_code(400); + echo json_encode(['error' => 'Repository path does not exist. Please clone first.']); + exit; +} + +// Perform sync +$logger->info($repo['id'], "Manual sync triggered"); + +$result = $gitHandler->pull( + $repo['id'], + $repo['target_path'], + $repo['branch'] +); + +if ($result['success']) { + echo json_encode([ + 'success' => true, + 'message' => $result['message'], + 'files_changed' => $result['files_changed'] ?? 0, + 'output' => $result['output'] + ]); +} else { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => $result['message'], + 'conflict' => $result['conflict'] ?? false, + 'error' => $result['error'] ?? null, + 'output' => $result['output'] ?? null + ]); +} diff --git a/gitpusher/public/css/style.css b/gitpusher/public/css/style.css new file mode 100644 index 0000000..1d9b1f4 --- /dev/null +++ b/gitpusher/public/css/style.css @@ -0,0 +1,633 @@ +/* Reset and Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary-color: #0366d6; + --success-color: #28a745; + --warning-color: #ffc107; + --error-color: #dc3545; + --info-color: #17a2b8; + --bg-color: #f6f8fa; + --card-bg: #ffffff; + --text-primary: #24292e; + --text-secondary: #586069; + --border-color: #e1e4e8; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); + --shadow-hover: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* Header */ +header { + text-align: center; + padding: 40px 20px; + background: linear-gradient(135deg, var(--primary-color), #0550ae); + color: white; + border-radius: 8px; + margin-bottom: 30px; + box-shadow: var(--shadow); +} + +header h1 { + font-size: 2.5em; + margin-bottom: 10px; +} + +.subtitle { + font-size: 1.1em; + opacity: 0.9; +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 30px; +} + +.stat-card { + background: var(--card-bg); + padding: 25px; + border-radius: 8px; + box-shadow: var(--shadow); + text-align: center; + transition: transform 0.2s, box-shadow 0.2s; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover); +} + +.stat-value { + font-size: 2.5em; + font-weight: bold; + color: var(--primary-color); + margin-bottom: 5px; +} + +.stat-label { + font-size: 0.9em; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Sections */ +.section { + background: var(--card-bg); + padding: 25px; + border-radius: 8px; + box-shadow: var(--shadow); + margin-bottom: 20px; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 2px solid var(--border-color); +} + +.section-header h2 { + font-size: 1.5em; + color: var(--text-primary); +} + +/* Buttons */ +.btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 5px; +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +.btn-primary { + background: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background: #0550ae; +} + +.btn-secondary { + background: var(--text-secondary); + color: white; +} + +.btn-secondary:hover { + background: #444d56; +} + +.btn-success { + background: var(--success-color); + color: white; +} + +.btn-warning { + background: var(--warning-color); + color: #212529; +} + +.btn-danger { + background: var(--error-color); + color: white; +} + +.btn-sm { + padding: 5px 12px; + font-size: 12px; +} + +/* Repository List */ +.repos-list { + display: grid; + gap: 15px; +} + +.repo-card { + background: var(--bg-color); + padding: 20px; + border-radius: 8px; + border-left: 4px solid var(--primary-color); + transition: all 0.2s; +} + +.repo-card:hover { + box-shadow: var(--shadow); +} + +.repo-card.status-synced { + border-left-color: var(--success-color); +} + +.repo-card.status-error { + border-left-color: var(--error-color); +} + +.repo-card.status-conflict { + border-left-color: var(--warning-color); +} + +.repo-card.status-cloning { + border-left-color: var(--info-color); +} + +.repo-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; +} + +.repo-name { + font-size: 1.3em; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 5px; +} + +.repo-url { + font-size: 0.9em; + color: var(--text-secondary); + font-family: 'Courier New', monospace; +} + +.repo-status { + padding: 5px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.repo-status.synced { + background: #d4edda; + color: #155724; +} + +.repo-status.error { + background: #f8d7da; + color: #721c24; +} + +.repo-status.conflict { + background: #fff3cd; + color: #856404; +} + +.repo-status.cloning { + background: #d1ecf1; + color: #0c5460; +} + +.repo-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-bottom: 15px; + padding: 15px; + background: white; + border-radius: 6px; +} + +.info-item { + display: flex; + flex-direction: column; +} + +.info-label { + font-size: 0.8em; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 3px; +} + +.info-value { + font-family: 'Courier New', monospace; + font-size: 0.9em; + color: var(--text-primary); +} + +.repo-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* Logs List */ +.logs-list { + max-height: 500px; + overflow-y: auto; +} + +.log-entry { + padding: 12px; + border-left: 3px solid var(--info-color); + background: var(--bg-color); + margin-bottom: 10px; + border-radius: 4px; + display: grid; + grid-template-columns: auto 1fr; + gap: 15px; + align-items: start; +} + +.log-entry.success { + border-left-color: var(--success-color); +} + +.log-entry.error { + border-left-color: var(--error-color); +} + +.log-entry.warning { + border-left-color: var(--warning-color); +} + +.log-entry.info { + border-left-color: var(--info-color); +} + +.log-timestamp { + font-size: 0.85em; + color: var(--text-secondary); + font-family: 'Courier New', monospace; + white-space: nowrap; +} + +.log-content { + flex: 1; +} + +.log-message { + color: var(--text-primary); + margin-bottom: 5px; +} + +.log-details { + font-size: 0.85em; + color: var(--text-secondary); + font-family: 'Courier New', monospace; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.modal.active { + display: flex; +} + +.modal-content { + background: var(--card-bg); + border-radius: 8px; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h2 { + margin: 0; + font-size: 1.5em; +} + +.close-btn { + background: none; + border: none; + font-size: 2em; + cursor: pointer; + color: var(--text-secondary); + line-height: 1; + padding: 0; + width: 30px; + height: 30px; +} + +.close-btn:hover { + color: var(--text-primary); +} + +.modal-body { + padding: 20px; +} + +/* Forms */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 600; + color: var(--text-primary); +} + +.form-group input[type="text"], +.form-group input[type="url"], +.form-group select { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 14px; + font-family: inherit; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.1); +} + +.form-group small { + display: block; + margin-top: 5px; + color: var(--text-secondary); + font-size: 0.85em; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.form-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 25px; + padding-top: 20px; + border-top: 1px solid var(--border-color); +} + +.input-with-copy { + display: flex; + gap: 10px; +} + +.input-with-copy input { + flex: 1; +} + +/* Commits List */ +.commits-list { + max-height: 400px; + overflow-y: auto; +} + +.commit-item { + padding: 15px; + border: 1px solid var(--border-color); + border-radius: 6px; + margin-bottom: 10px; + cursor: pointer; + transition: all 0.2s; +} + +.commit-item:hover { + background: var(--bg-color); + border-color: var(--primary-color); +} + +.commit-hash { + font-family: 'Courier New', monospace; + font-weight: 600; + color: var(--primary-color); + margin-bottom: 5px; +} + +.commit-message { + color: var(--text-primary); + margin-bottom: 5px; +} + +.commit-meta { + font-size: 0.85em; + color: var(--text-secondary); +} + +/* Alert */ +.alert { + padding: 15px; + border-radius: 6px; + margin-bottom: 15px; +} + +.alert-info { + background: #d1ecf1; + border: 1px solid #bee5eb; + color: #0c5460; +} + +.alert-info ol { + margin: 10px 0 0 20px; +} + +/* Toast Notifications */ +#toastContainer { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 10px; +} + +.toast { + background: white; + padding: 15px 20px; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 300px; + display: flex; + align-items: center; + gap: 10px; + animation: slideIn 0.3s ease; +} + +.toast.success { + border-left: 4px solid var(--success-color); +} + +.toast.error { + border-left: 4px solid var(--error-color); +} + +.toast.warning { + border-left: 4px solid var(--warning-color); +} + +.toast.info { + border-left: 4px solid var(--info-color); +} + +@keyframes slideIn { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Loading */ +.loading { + text-align: center; + padding: 40px; + color: var(--text-secondary); +} + +.loading::before { + content: "⏳ "; + font-size: 1.5em; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.empty-state::before { + content: "📁"; + font-size: 4em; + display: block; + margin-bottom: 20px; +} + +/* Responsive */ +@media (max-width: 768px) { + header h1 { + font-size: 1.8em; + } + + .section-header { + flex-direction: column; + align-items: flex-start; + gap: 15px; + } + + .repo-header { + flex-direction: column; + gap: 10px; + } + + .repo-actions { + width: 100%; + } + + .repo-actions .btn { + flex: 1; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/gitpusher/public/index.php b/gitpusher/public/index.php new file mode 100644 index 0000000..38deee4 --- /dev/null +++ b/gitpusher/public/index.php @@ -0,0 +1,181 @@ + + + + + + GitHub Sync - Dashboard + + + +
+
+

🔄 GitHub Sync Dashboard

+

Automatische Repository-Synchronisation

+
+ +
+
+
0
+
Repositories
+
+
+
0
+
Synchronisiert
+
+
+
0
+
Fehler
+
+
+
0
+
Log-Einträge (24h)
+
+
+ +
+
+

Repositories

+ +
+ +
+
Lade Repositories...
+
+
+ +
+
+

Letzte Ereignisse

+ +
+ +
+
Lade Logs...
+
+
+
+ + + + + + + + + + + +
+ + + + diff --git a/gitpusher/public/js/app.js b/gitpusher/public/js/app.js new file mode 100644 index 0000000..af71f43 --- /dev/null +++ b/gitpusher/public/js/app.js @@ -0,0 +1,516 @@ +/** + * GitHub Sync Dashboard - Frontend JavaScript + */ + +// State +let repositories = []; +let logs = []; +let stats = {}; +let currentRollbackRepoId = null; + +// Initialize app when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + loadDashboard(); + // Auto-refresh every 30 seconds + setInterval(loadDashboard, 30000); +}); + +/** + * Load complete dashboard + */ +async function loadDashboard() { + await Promise.all([ + loadRepositories(), + loadLogs() + ]); + updateStats(); +} + +/** + * Load repositories + */ +async function loadRepositories() { + try { + const response = await fetch('api/repos.php'); + const data = await response.json(); + + if (data.repositories) { + repositories = data.repositories; + renderRepositories(); + } + } catch (error) { + console.error('Error loading repositories:', error); + showToast('Fehler beim Laden der Repositories', 'error'); + } +} + +/** + * Render repositories list + */ +function renderRepositories() { + const container = document.getElementById('reposList'); + + if (repositories.length === 0) { + container.innerHTML = '
Keine Repositories konfiguriert. Füge dein erstes Repository hinzu!
'; + return; + } + + container.innerHTML = repositories.map(repo => ` +
+
+
+
${escapeHtml(repo.name)}
+
${escapeHtml(repo.repo_url)}
+
+ ${getStatusText(repo.status)} +
+ +
+
+ Branch + ${escapeHtml(repo.branch)} +
+
+ Ziel-Pfad + ${escapeHtml(repo.target_path)} +
+
+ Letzte Sync + ${repo.last_sync || 'Noch nie'} +
+
+ Auto-Sync + ${repo.auto_sync ? '✅ Aktiv' : '❌ Inaktiv'} +
+
+ +
+ + + + +
+
+ `).join(''); +} + +/** + * Load logs + */ +async function loadLogs() { + try { + const response = await fetch('api/log.php?limit=50'); + const data = await response.json(); + + if (data.success) { + logs = data.logs; + stats = data.stats; + renderLogs(); + } + } catch (error) { + console.error('Error loading logs:', error); + showToast('Fehler beim Laden der Logs', 'error'); + } +} + +/** + * Render logs list + */ +function renderLogs() { + const container = document.getElementById('logsList'); + + if (logs.length === 0) { + container.innerHTML = '
Noch keine Log-Einträge vorhanden.
'; + return; + } + + container.innerHTML = logs.map(log => { + const repo = repositories.find(r => r.id === log.repo_id); + const repoName = repo ? repo.name : log.repo_id; + + return ` +
+
${log.timestamp}
+
+
+ ${escapeHtml(repoName)} - ${escapeHtml(log.message)} +
+ ${log.details && Object.keys(log.details).length > 0 ? ` +
${formatLogDetails(log.details)}
+ ` : ''} +
+
+ `; + }).join(''); +} + +/** + * Update statistics + */ +function updateStats() { + const totalRepos = repositories.length; + const syncedRepos = repositories.filter(r => r.status === 'synced').length; + const errorRepos = repositories.filter(r => r.status === 'error' || r.status === 'conflict').length; + + document.getElementById('totalRepos').textContent = totalRepos; + document.getElementById('syncedRepos').textContent = syncedRepos; + document.getElementById('errorRepos').textContent = errorRepos; + document.getElementById('totalLogs').textContent = stats.last_24h || 0; +} + +/** + * Show add repository modal + */ +function showAddRepoModal() { + document.getElementById('addRepoModal').classList.add('active'); +} + +/** + * Add repository + */ +async function addRepository(event) { + event.preventDefault(); + + const form = event.target; + const formData = new FormData(form); + + const data = { + name: formData.get('name'), + repo_url: formData.get('repo_url'), + branch: formData.get('branch'), + target_path: formData.get('target_path'), + auto_sync: formData.get('auto_sync') === 'on' + }; + + try { + const response = await fetch('api/repos.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.success) { + showToast('Repository erfolgreich hinzugefügt!', 'success'); + closeModal('addRepoModal'); + form.reset(); + + // Show webhook info + if (result.repository.webhook_secret) { + showWebhookInfoData(result.repository.webhook_url, result.repository.webhook_secret); + } + + loadDashboard(); + } else { + showToast(result.error || 'Fehler beim Hinzufügen des Repositories', 'error'); + } + } catch (error) { + console.error('Error adding repository:', error); + showToast('Fehler beim Hinzufügen des Repositories', 'error'); + } +} + +/** + * Sync repository manually + */ +async function syncRepository(repoId) { + const repo = repositories.find(r => r.id === repoId); + + if (!repo) return; + + showToast(`Synchronisiere ${repo.name}...`, 'info'); + + try { + const response = await fetch('api/sync.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ repo_id: repoId }) + }); + + const result = await response.json(); + + if (result.success) { + showToast(`${repo.name} erfolgreich synchronisiert! ${result.files_changed} Datei(en) geändert.`, 'success'); + loadDashboard(); + } else { + if (result.conflict) { + showToast(`Merge-Konflikt in ${repo.name}! Manuelle Lösung erforderlich.`, 'warning'); + } else { + showToast(result.message || 'Fehler beim Synchronisieren', 'error'); + } + loadDashboard(); + } + } catch (error) { + console.error('Error syncing repository:', error); + showToast('Fehler beim Synchronisieren', 'error'); + } +} + +/** + * Show rollback modal + */ +async function showRollbackModal(repoId) { + currentRollbackRepoId = repoId; + const modal = document.getElementById('rollbackModal'); + const commitsList = document.getElementById('commitsList'); + + modal.classList.add('active'); + commitsList.innerHTML = '
Lade Commits...
'; + + try { + const response = await fetch(`api/rollback.php?repo_id=${repoId}&limit=20`); + const result = await response.json(); + + if (result.success && result.commits.length > 0) { + commitsList.innerHTML = result.commits.map(commit => ` +
+
${commit.hash_short}
+
${escapeHtml(commit.message)}
+
+ ${escapeHtml(commit.author_name)} - ${commit.timestamp} +
+
+ `).join(''); + } else { + commitsList.innerHTML = '
Keine Commits gefunden.
'; + } + } catch (error) { + console.error('Error loading commits:', error); + commitsList.innerHTML = '
Fehler beim Laden der Commits.
'; + } +} + +/** + * Perform rollback + */ +async function performRollback(commitHash) { + if (!currentRollbackRepoId) return; + + const repo = repositories.find(r => r.id === currentRollbackRepoId); + + if (!confirm(`Möchtest du wirklich einen Rollback zu Commit ${commitHash.substring(0, 7)} durchführen?\n\nDies erstellt einen neuen Revert-Commit.`)) { + return; + } + + showToast(`Führe Rollback durch...`, 'info'); + + try { + const response = await fetch('api/rollback.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + repo_id: currentRollbackRepoId, + commit_hash: commitHash + }) + }); + + const result = await response.json(); + + if (result.success) { + showToast('Rollback erfolgreich durchgeführt!', 'success'); + closeModal('rollbackModal'); + loadDashboard(); + } else { + showToast(result.message || 'Fehler beim Rollback', 'error'); + } + } catch (error) { + console.error('Error performing rollback:', error); + showToast('Fehler beim Rollback', 'error'); + } +} + +/** + * Show webhook info modal + */ +async function showWebhookInfo(repoId) { + const repo = repositories.find(r => r.id === repoId); + + if (!repo) return; + + // Fetch webhook secret from config + try { + const response = await fetch(`api/repos.php?id=${repoId}`); + const data = await response.json(); + + const webhookUrl = window.location.origin + window.location.pathname.replace('index.php', '') + 'webhook.php'; + const webhookSecret = '(Secret gespeichert auf Server)'; + + showWebhookInfoData(webhookUrl, webhookSecret); + } catch (error) { + console.error('Error loading webhook info:', error); + } +} + +/** + * Show webhook info with data + */ +function showWebhookInfoData(url, secret) { + document.getElementById('webhookUrl').value = url; + document.getElementById('webhookSecret').value = secret; + document.getElementById('webhookModal').classList.add('active'); +} + +/** + * Delete repository + */ +async function deleteRepository(repoId, repoName) { + const deleteFiles = confirm(`Repository "${repoName}" aus Konfiguration entfernen?\n\nKlicke OK, um auch die Dateien vom Server zu löschen.\nKlicke Abbrechen, um nur die Konfiguration zu entfernen.`); + + if (deleteFiles === null) return; // User cancelled + + try { + const response = await fetch('api/repos.php', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id: repoId, + delete_files: deleteFiles + }) + }); + + const result = await response.json(); + + if (result.success) { + showToast(`Repository "${repoName}" wurde entfernt.`, 'success'); + loadDashboard(); + } else { + showToast(result.error || 'Fehler beim Löschen', 'error'); + } + } catch (error) { + console.error('Error deleting repository:', error); + showToast('Fehler beim Löschen', 'error'); + } +} + +/** + * Fetch branches from GitHub + */ +async function fetchBranches() { + const repoUrl = document.getElementById('repoUrl').value; + + if (!repoUrl) return; + + const branchSelect = document.getElementById('repoBranch'); + const branchLoading = document.getElementById('branchLoading'); + + branchLoading.style.display = 'block'; + + try { + // This would need to be implemented in the backend + // For now, keep default branches + branchLoading.style.display = 'none'; + } catch (error) { + console.error('Error fetching branches:', error); + branchLoading.style.display = 'none'; + } +} + +/** + * Refresh logs + */ +function refreshLogs() { + loadLogs(); + showToast('Logs aktualisiert', 'info'); +} + +/** + * Close modal + */ +function closeModal(modalId) { + document.getElementById(modalId).classList.remove('active'); +} + +/** + * Show toast notification + */ +function showToast(message, type = 'info') { + const container = document.getElementById('toastContainer'); + const toast = document.createElement('div'); + + toast.className = `toast ${type}`; + toast.textContent = message; + + container.appendChild(toast); + + // Auto-remove after 5 seconds + setTimeout(() => { + toast.style.opacity = '0'; + setTimeout(() => toast.remove(), 300); + }, 5000); +} + +/** + * Copy text to clipboard + */ +function copyToClipboard(elementId) { + const element = document.getElementById(elementId); + element.select(); + document.execCommand('copy'); + showToast('In Zwischenablage kopiert!', 'success'); +} + +/** + * Helper: Escape HTML + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Helper: Get status text + */ +function getStatusText(status) { + const statusMap = { + 'synced': '✅ Synchronisiert', + 'cloning': '⏳ Wird geklont...', + 'error': '❌ Fehler', + 'conflict': '⚠️ Konflikt', + 'pending': '⏸️ Ausstehend' + }; + + return statusMap[status] || status; +} + +/** + * Helper: Format log details + */ +function formatLogDetails(details) { + return Object.entries(details) + .map(([key, value]) => `${key}: ${value}`) + .join(' | '); +} + +// Close modal when clicking outside +window.addEventListener('click', function(event) { + if (event.target.classList.contains('modal')) { + event.target.classList.remove('active'); + } +}); + +// Close modal with Escape key +window.addEventListener('keydown', function(event) { + if (event.key === 'Escape') { + document.querySelectorAll('.modal.active').forEach(modal => { + modal.classList.remove('active'); + }); + } +}); diff --git a/gitpusher/public/webhook.php b/gitpusher/public/webhook.php new file mode 100644 index 0000000..2be668b --- /dev/null +++ b/gitpusher/public/webhook.php @@ -0,0 +1,131 @@ + 'Method not allowed']); + exit; +} + +// Get payload +$payload = file_get_contents('php://input'); +$data = json_decode($payload, true); + +if (json_last_error() !== JSON_ERROR_NONE) { + http_response_code(400); + echo json_encode(['error' => 'Invalid JSON payload']); + exit; +} + +// Initialize classes +$configManager = new ConfigManager(); +$logger = new Logger($configManager); +$gitHandler = new GitHandler($logger, $configManager); + +// Get repository URL from payload +$repoUrl = $data['repository']['clone_url'] ?? null; + +if (!$repoUrl) { + http_response_code(400); + echo json_encode(['error' => 'Repository URL not found in payload']); + exit; +} + +// Find matching repository in config +$repos = $configManager->getRepositories(); +$matchedRepo = null; + +foreach ($repos as $repo) { + if ($repo['repo_url'] === $repoUrl) { + $matchedRepo = $repo; + break; + } +} + +if (!$matchedRepo) { + http_response_code(404); + $logger->warning('webhook', "Webhook received for unknown repository: $repoUrl"); + echo json_encode(['error' => 'Repository not configured']); + exit; +} + +// Verify webhook signature if secret is configured +$webhookSecret = $configManager->getWebhookSecret($matchedRepo['id']); + +if ($webhookSecret) { + $signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? ''; + + if (empty($signature)) { + http_response_code(401); + $logger->error($matchedRepo['id'], "Webhook signature missing"); + echo json_encode(['error' => 'Signature required']); + exit; + } + + $expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $webhookSecret); + + if (!hash_equals($expectedSignature, $signature)) { + http_response_code(401); + $logger->error($matchedRepo['id'], "Invalid webhook signature"); + echo json_encode(['error' => 'Invalid signature']); + exit; + } +} + +// Check if the push is for the configured branch +$ref = $data['ref'] ?? ''; +$pushedBranch = str_replace('refs/heads/', '', $ref); + +if ($pushedBranch !== $matchedRepo['branch']) { + $logger->info($matchedRepo['id'], "Ignoring push to branch '$pushedBranch' (configured: '{$matchedRepo['branch']}')"); + echo json_encode([ + 'success' => true, + 'message' => 'Ignored - different branch', + 'pushed_branch' => $pushedBranch, + 'configured_branch' => $matchedRepo['branch'] + ]); + exit; +} + +// Log webhook received +$commits = $data['commits'] ?? []; +$logger->info($matchedRepo['id'], "Webhook received: " . count($commits) . " commits pushed", [ + 'pusher' => $data['pusher']['name'] ?? 'unknown', + 'branch' => $pushedBranch +]); + +// Perform git pull +$result = $gitHandler->pull( + $matchedRepo['id'], + $matchedRepo['target_path'], + $matchedRepo['branch'] +); + +// Return result +if ($result['success']) { + http_response_code(200); + echo json_encode([ + 'success' => true, + 'message' => $result['message'], + 'files_changed' => $result['files_changed'] ?? 0 + ]); +} else { + // Still return 200 to GitHub, but log the error + http_response_code(200); + echo json_encode([ + 'success' => false, + 'message' => $result['message'], + 'conflict' => $result['conflict'] ?? false + ]); +} diff --git a/gitpusher/src/.htaccess b/gitpusher/src/.htaccess new file mode 100644 index 0000000..9694c0e --- /dev/null +++ b/gitpusher/src/.htaccess @@ -0,0 +1,8 @@ +# Deny all access to source directory +Require all denied + +# Alternative for older Apache versions + + Order deny,allow + Deny from all + diff --git a/gitpusher/src/ConfigManager.php b/gitpusher/src/ConfigManager.php new file mode 100644 index 0000000..850c095 --- /dev/null +++ b/gitpusher/src/ConfigManager.php @@ -0,0 +1,199 @@ +dataDir = $dataDir; + $this->ensureDataDirExists(); + } + + /** + * Ensure data directory exists with proper permissions + */ + private function ensureDataDirExists() { + if (!file_exists($this->dataDir)) { + mkdir($this->dataDir, 0755, true); + } + } + + /** + * Read JSON file + */ + public function read($filename) { + $filepath = $this->dataDir . '/' . $filename; + + if (!file_exists($filepath)) { + return []; + } + + $content = file_get_contents($filepath); + $data = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + error_log("JSON decode error in $filename: " . json_last_error_msg()); + return []; + } + + return $data; + } + + /** + * Write JSON file + */ + public function write($filename, $data) { + $filepath = $this->dataDir . '/' . $filename; + + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if ($json === false) { + error_log("JSON encode error: " . json_last_error_msg()); + return false; + } + + $result = file_put_contents($filepath, $json); + + if ($result === false) { + error_log("Failed to write file: $filepath"); + return false; + } + + // Set appropriate permissions (readable only by owner) + chmod($filepath, 0600); + + return true; + } + + /** + * Get all repositories from config + */ + public function getRepositories() { + $config = $this->read('config.json'); + return $config['repositories'] ?? []; + } + + /** + * Get repository by ID + */ + public function getRepository($repoId) { + $repos = $this->getRepositories(); + + foreach ($repos as $repo) { + if ($repo['id'] === $repoId) { + return $repo; + } + } + + return null; + } + + /** + * Add new repository + */ + public function addRepository($repoData) { + $config = $this->read('config.json'); + + if (!isset($config['repositories'])) { + $config['repositories'] = []; + } + + // Generate unique ID + $repoData['id'] = uniqid('repo_', true); + $repoData['created_at'] = date('Y-m-d H:i:s'); + $repoData['status'] = 'pending'; + + $config['repositories'][] = $repoData; + + return $this->write('config.json', $config) ? $repoData['id'] : false; + } + + /** + * Update repository + */ + public function updateRepository($repoId, $updates) { + $config = $this->read('config.json'); + + if (!isset($config['repositories'])) { + return false; + } + + foreach ($config['repositories'] as &$repo) { + if ($repo['id'] === $repoId) { + $repo = array_merge($repo, $updates); + $repo['updated_at'] = date('Y-m-d H:i:s'); + return $this->write('config.json', $config); + } + } + + return false; + } + + /** + * Delete repository + */ + public function deleteRepository($repoId) { + $config = $this->read('config.json'); + + if (!isset($config['repositories'])) { + return false; + } + + $config['repositories'] = array_filter($config['repositories'], function($repo) use ($repoId) { + return $repo['id'] !== $repoId; + }); + + // Re-index array + $config['repositories'] = array_values($config['repositories']); + + return $this->write('config.json', $config); + } + + /** + * Get GitHub Personal Access Token + */ + public function getGitHubToken() { + $secrets = $this->read('secrets.json'); + return $secrets['github_pat'] ?? null; + } + + /** + * Set GitHub Personal Access Token + */ + public function setGitHubToken($token) { + $secrets = $this->read('secrets.json'); + $secrets['github_pat'] = $token; + return $this->write('secrets.json', $secrets); + } + + /** + * Get webhook secret for a repository + */ + public function getWebhookSecret($repoId) { + $secrets = $this->read('secrets.json'); + return $secrets['webhook_secrets'][$repoId] ?? null; + } + + /** + * Set webhook secret for a repository + */ + public function setWebhookSecret($repoId, $secret) { + $secrets = $this->read('secrets.json'); + + if (!isset($secrets['webhook_secrets'])) { + $secrets['webhook_secrets'] = []; + } + + $secrets['webhook_secrets'][$repoId] = $secret; + + return $this->write('secrets.json', $secrets); + } + + /** + * Generate secure webhook secret + */ + public function generateWebhookSecret() { + return bin2hex(random_bytes(32)); + } +} diff --git a/gitpusher/src/GitHandler.php b/gitpusher/src/GitHandler.php new file mode 100644 index 0000000..e0d7277 --- /dev/null +++ b/gitpusher/src/GitHandler.php @@ -0,0 +1,399 @@ +logger = $logger; + $this->configManager = $configManager; + } + + /** + * Execute shell command and return result + */ + private function exec($command, $cwd = null) { + $descriptorspec = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'] // stderr + ]; + + $process = proc_open($command, $descriptorspec, $pipes, $cwd); + + if (!is_resource($process)) { + return [ + 'success' => false, + 'output' => '', + 'error' => 'Failed to execute command', + 'exit_code' => -1 + ]; + } + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + return [ + 'success' => $exitCode === 0, + 'output' => trim($stdout), + 'error' => trim($stderr), + 'exit_code' => $exitCode + ]; + } + + /** + * Build Git URL with authentication token + */ + private function buildGitUrl($repoUrl, $token) { + // Convert https://github.com/user/repo.git to https://TOKEN@github.com/user/repo.git + $parsed = parse_url($repoUrl); + + if (!$parsed || !isset($parsed['host'])) { + return $repoUrl; + } + + $scheme = $parsed['scheme'] ?? 'https'; + $host = $parsed['host']; + $path = $parsed['path'] ?? ''; + + return "$scheme://$token@$host$path"; + } + + /** + * Clone repository + */ + public function cloneRepository($repoId, $repoUrl, $targetPath, $branch = 'main') { + $this->logger->info($repoId, "Starting clone of repository to $targetPath"); + + // Check if target path already exists + if (file_exists($targetPath)) { + $this->logger->error($repoId, "Target path already exists: $targetPath"); + return [ + 'success' => false, + 'message' => 'Target path already exists' + ]; + } + + // Create parent directory if it doesn't exist + $parentDir = dirname($targetPath); + if (!file_exists($parentDir)) { + mkdir($parentDir, 0755, true); + } + + // Get GitHub token + $token = $this->configManager->getGitHubToken(); + $gitUrl = $token ? $this->buildGitUrl($repoUrl, $token) : $repoUrl; + + // Clone repository + $command = sprintf( + 'git clone --branch %s %s %s 2>&1', + escapeshellarg($branch), + escapeshellarg($gitUrl), + escapeshellarg($targetPath) + ); + + $result = $this->exec($command); + + if ($result['success']) { + $this->logger->success($repoId, "Repository cloned successfully", [ + 'path' => $targetPath, + 'branch' => $branch + ]); + + return [ + 'success' => true, + 'message' => 'Repository cloned successfully', + 'output' => $result['output'] + ]; + } else { + $this->logger->error($repoId, "Failed to clone repository", [ + 'error' => $result['error'], + 'output' => $result['output'] + ]); + + return [ + 'success' => false, + 'message' => 'Failed to clone repository', + 'error' => $result['error'] + ]; + } + } + + /** + * Pull latest changes + */ + public function pull($repoId, $targetPath, $branch = 'main') { + $this->logger->info($repoId, "Starting pull for $targetPath"); + + // Check if path exists + if (!file_exists($targetPath)) { + $this->logger->error($repoId, "Repository path does not exist: $targetPath"); + return [ + 'success' => false, + 'message' => 'Repository path does not exist' + ]; + } + + // Check if it's a git repository + if (!file_exists("$targetPath/.git")) { + $this->logger->error($repoId, "Not a git repository: $targetPath"); + return [ + 'success' => false, + 'message' => 'Not a git repository' + ]; + } + + // Get current commit before pull + $currentCommit = $this->getCurrentCommit($targetPath); + + // Pull changes + $command = sprintf( + 'cd %s && git pull origin %s 2>&1', + escapeshellarg($targetPath), + escapeshellarg($branch) + ); + + $result = $this->exec($command); + + // Check for merge conflicts + if (!$result['success'] || strpos($result['output'], 'CONFLICT') !== false) { + $this->logger->warning($repoId, "Merge conflict detected", [ + 'output' => $result['output'], + 'error' => $result['error'] + ]); + + $this->configManager->updateRepository($repoId, ['status' => 'conflict']); + + return [ + 'success' => false, + 'message' => 'Merge conflict detected', + 'conflict' => true, + 'output' => $result['output'] + ]; + } + + // Get new commit after pull + $newCommit = $this->getCurrentCommit($targetPath); + + // Count changed files + $changedFiles = $this->getChangedFilesBetweenCommits($targetPath, $currentCommit, $newCommit); + + if ($result['success']) { + $this->logger->success($repoId, "Pull completed successfully", [ + 'files_changed' => count($changedFiles), + 'old_commit' => substr($currentCommit, 0, 7), + 'new_commit' => substr($newCommit, 0, 7) + ]); + + $this->configManager->updateRepository($repoId, [ + 'status' => 'synced', + 'last_sync' => date('Y-m-d H:i:s'), + 'last_commit' => $newCommit + ]); + + return [ + 'success' => true, + 'message' => 'Pull completed successfully', + 'files_changed' => count($changedFiles), + 'output' => $result['output'] + ]; + } else { + $this->logger->error($repoId, "Pull failed", [ + 'error' => $result['error'], + 'output' => $result['output'] + ]); + + return [ + 'success' => false, + 'message' => 'Pull failed', + 'error' => $result['error'] + ]; + } + } + + /** + * Revert to specific commit + */ + public function revert($repoId, $targetPath, $commitHash) { + $this->logger->info($repoId, "Starting revert to commit $commitHash"); + + if (!file_exists($targetPath)) { + return [ + 'success' => false, + 'message' => 'Repository path does not exist' + ]; + } + + // Create revert commit + $command = sprintf( + 'cd %s && git revert --no-edit %s 2>&1', + escapeshellarg($targetPath), + escapeshellarg($commitHash) + ); + + $result = $this->exec($command); + + if ($result['success']) { + $this->logger->success($repoId, "Reverted to commit $commitHash", [ + 'commit' => $commitHash + ]); + + $newCommit = $this->getCurrentCommit($targetPath); + + $this->configManager->updateRepository($repoId, [ + 'last_commit' => $newCommit, + 'last_sync' => date('Y-m-d H:i:s') + ]); + + return [ + 'success' => true, + 'message' => 'Revert completed successfully', + 'output' => $result['output'] + ]; + } else { + $this->logger->error($repoId, "Revert failed", [ + 'error' => $result['error'] + ]); + + return [ + 'success' => false, + 'message' => 'Revert failed', + 'error' => $result['error'] + ]; + } + } + + /** + * Get current commit hash + */ + public function getCurrentCommit($targetPath) { + $result = $this->exec("cd " . escapeshellarg($targetPath) . " && git rev-parse HEAD 2>&1"); + return $result['success'] ? trim($result['output']) : null; + } + + /** + * Get commit history + */ + public function getCommitHistory($targetPath, $limit = 20) { + $command = sprintf( + 'cd %s && git log --pretty=format:"%%H|%%an|%%ae|%%at|%%s" -n %d 2>&1', + escapeshellarg($targetPath), + (int)$limit + ); + + $result = $this->exec($command); + + if (!$result['success']) { + return []; + } + + $commits = []; + $lines = explode("\n", $result['output']); + + foreach ($lines as $line) { + if (empty($line)) continue; + + $parts = explode('|', $line); + if (count($parts) !== 5) continue; + + $commits[] = [ + 'hash' => $parts[0], + 'hash_short' => substr($parts[0], 0, 7), + 'author_name' => $parts[1], + 'author_email' => $parts[2], + 'timestamp' => date('Y-m-d H:i:s', (int)$parts[3]), + 'message' => $parts[4] + ]; + } + + return $commits; + } + + /** + * Get changed files between commits + */ + private function getChangedFilesBetweenCommits($targetPath, $oldCommit, $newCommit) { + if ($oldCommit === $newCommit) { + return []; + } + + $command = sprintf( + 'cd %s && git diff --name-only %s %s 2>&1', + escapeshellarg($targetPath), + escapeshellarg($oldCommit), + escapeshellarg($newCommit) + ); + + $result = $this->exec($command); + + if (!$result['success']) { + return []; + } + + return array_filter(explode("\n", $result['output'])); + } + + /** + * Get repository status + */ + public function getStatus($targetPath) { + $command = sprintf( + 'cd %s && git status --porcelain 2>&1', + escapeshellarg($targetPath) + ); + + $result = $this->exec($command); + + return [ + 'success' => $result['success'], + 'clean' => $result['success'] && empty($result['output']), + 'output' => $result['output'] + ]; + } + + /** + * Get current branch + */ + public function getCurrentBranch($targetPath) { + $result = $this->exec("cd " . escapeshellarg($targetPath) . " && git branch --show-current 2>&1"); + return $result['success'] ? trim($result['output']) : null; + } + + /** + * Fetch available branches from remote + */ + public function getRemoteBranches($repoUrl) { + $token = $this->configManager->getGitHubToken(); + $gitUrl = $token ? $this->buildGitUrl($repoUrl, $token) : $repoUrl; + + $command = sprintf( + 'git ls-remote --heads %s 2>&1', + escapeshellarg($gitUrl) + ); + + $result = $this->exec($command); + + if (!$result['success']) { + return []; + } + + $branches = []; + $lines = explode("\n", $result['output']); + + foreach ($lines as $line) { + if (preg_match('/refs\/heads\/(.+)$/', $line, $matches)) { + $branches[] = $matches[1]; + } + } + + return $branches; + } +} diff --git a/gitpusher/src/Logger.php b/gitpusher/src/Logger.php new file mode 100644 index 0000000..a002406 --- /dev/null +++ b/gitpusher/src/Logger.php @@ -0,0 +1,166 @@ +configManager = $configManager; + } + + /** + * Add log entry + */ + public function log($repoId, $type, $message, $details = []) { + $logs = $this->configManager->read('log.json'); + + if (!isset($logs['entries'])) { + $logs['entries'] = []; + } + + $entry = [ + 'id' => uniqid('log_', true), + 'timestamp' => date('Y-m-d H:i:s'), + 'repo_id' => $repoId, + 'type' => $type, // success, error, warning, info + 'message' => $message, + 'details' => $details + ]; + + // Add to beginning of array + array_unshift($logs['entries'], $entry); + + // Keep only last N entries + if (count($logs['entries']) > $this->maxLogEntries) { + $logs['entries'] = array_slice($logs['entries'], 0, $this->maxLogEntries); + } + + $this->configManager->write('log.json', $logs); + + // Also log to PHP error log for debugging + error_log("[GitPusher] [$type] $repoId: $message"); + + return $entry['id']; + } + + /** + * Log success + */ + public function success($repoId, $message, $details = []) { + return $this->log($repoId, 'success', $message, $details); + } + + /** + * Log error + */ + public function error($repoId, $message, $details = []) { + return $this->log($repoId, 'error', $message, $details); + } + + /** + * Log warning + */ + public function warning($repoId, $message, $details = []) { + return $this->log($repoId, 'warning', $message, $details); + } + + /** + * Log info + */ + public function info($repoId, $message, $details = []) { + return $this->log($repoId, 'info', $message, $details); + } + + /** + * Get all log entries + */ + public function getAll($limit = 100, $offset = 0) { + $logs = $this->configManager->read('log.json'); + $entries = $logs['entries'] ?? []; + + return array_slice($entries, $offset, $limit); + } + + /** + * Get logs for specific repository + */ + public function getByRepository($repoId, $limit = 100) { + $logs = $this->configManager->read('log.json'); + $entries = $logs['entries'] ?? []; + + $filtered = array_filter($entries, function($entry) use ($repoId) { + return $entry['repo_id'] === $repoId; + }); + + return array_slice(array_values($filtered), 0, $limit); + } + + /** + * Get logs by type + */ + public function getByType($type, $limit = 100) { + $logs = $this->configManager->read('log.json'); + $entries = $logs['entries'] ?? []; + + $filtered = array_filter($entries, function($entry) use ($type) { + return $entry['type'] === $type; + }); + + return array_slice(array_values($filtered), 0, $limit); + } + + /** + * Clear all logs + */ + public function clear() { + return $this->configManager->write('log.json', ['entries' => []]); + } + + /** + * Clear logs for specific repository + */ + public function clearByRepository($repoId) { + $logs = $this->configManager->read('log.json'); + $entries = $logs['entries'] ?? []; + + $filtered = array_filter($entries, function($entry) use ($repoId) { + return $entry['repo_id'] !== $repoId; + }); + + $logs['entries'] = array_values($filtered); + + return $this->configManager->write('log.json', $logs); + } + + /** + * Get statistics + */ + public function getStats() { + $logs = $this->configManager->read('log.json'); + $entries = $logs['entries'] ?? []; + + $stats = [ + 'total' => count($entries), + 'success' => 0, + 'error' => 0, + 'warning' => 0, + 'info' => 0, + 'last_24h' => 0 + ]; + + $yesterday = strtotime('-24 hours'); + + foreach ($entries as $entry) { + $stats[$entry['type']]++; + + $timestamp = strtotime($entry['timestamp']); + if ($timestamp >= $yesterday) { + $stats['last_24h']++; + } + } + + return $stats; + } +}