Merge pull request #1 from metacube2/claude/github-sync-website-017YXsy55JgZ3uUCZx13NfZG

GitHub Sync Website with Apache Server
This commit is contained in:
2025-12-06 10:55:26 +01:00
committed by GitHub
18 changed files with 3818 additions and 0 deletions
+24
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
# Deny access to data directory from web
<FilesMatch "^(data|src)">
Require all denied
</FilesMatch>
# Deny access to sensitive files
<FilesMatch "\.(json|log|ini|conf)$">
Require all denied
</FilesMatch>
# Protect .git directory if exists
<DirectoryMatch "\.git">
Require all denied
</DirectoryMatch>
+284
View File
@@ -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
<VirtualHost *:80>
ServerName github-sync.local
DocumentRoot /gitpusher/public
<Directory /gitpusher/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<Directory /gitpusher/data>
Require all denied
</Directory>
<Directory /gitpusher/src>
Require all denied
</Directory>
ErrorLog ${APACHE_LOG_DIR}/github-sync-error.log
CustomLog ${APACHE_LOG_DIR}/github-sync-access.log combined
</VirtualHost>
```
```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!** 🚀
+447
View File
@@ -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
+341
View File
@@ -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
<VirtualHost *:80>
ServerName github-sync.deine-domain.de
DocumentRoot /gitpusher/public
<Directory /gitpusher/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# Deny access to data and src directories
<Directory /gitpusher/data>
Require all denied
</Directory>
<Directory /gitpusher/src>
Require all denied
</Directory>
ErrorLog ${APACHE_LOG_DIR}/github-sync-error.log
CustomLog ${APACHE_LOG_DIR}/github-sync-access.log combined
</VirtualHost>
```
#### 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
+8
View File
@@ -0,0 +1,8 @@
# Deny all access to data directory
Require all denied
# Alternative for older Apache versions
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* Log API
* Retrieves log entries
*/
require_once '../../src/ConfigManager.php';
require_once '../../src/Logger.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['error' => '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)
]);
+245
View File
@@ -0,0 +1,245 @@
<?php
/**
* Repository Management API
* Handles CRUD operations for repositories
*/
require_once '../../src/ConfigManager.php';
require_once '../../src/Logger.php';
require_once '../../src/GitHandler.php';
header('Content-Type: application/json');
$configManager = new ConfigManager();
$logger = new Logger($configManager);
$gitHandler = new GitHandler($logger, $configManager);
$method = $_SERVER['REQUEST_METHOD'];
// GET - List all repositories or get single repository
if ($method === 'GET') {
if (isset($_GET['id'])) {
$repo = $configManager->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']);
+107
View File
@@ -0,0 +1,107 @@
<?php
/**
* Rollback API
* Reverts repository to a specific commit
*/
require_once '../../src/ConfigManager.php';
require_once '../../src/Logger.php';
require_once '../../src/GitHandler.php';
header('Content-Type: application/json');
$configManager = new ConfigManager();
$logger = new Logger($configManager);
$gitHandler = new GitHandler($logger, $configManager);
$method = $_SERVER['REQUEST_METHOD'];
// GET - Get commit history
if ($method === 'GET') {
if (empty($_GET['repo_id'])) {
http_response_code(400);
echo json_encode(['error' => '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']);
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* Manual Sync API
* Triggers manual sync for a repository
*/
require_once '../../src/ConfigManager.php';
require_once '../../src/Logger.php';
require_once '../../src/GitHandler.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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
]);
}
+633
View File
@@ -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);
}
}
+181
View File
@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GitHub Sync - Dashboard</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="container">
<header>
<h1>🔄 GitHub Sync Dashboard</h1>
<p class="subtitle">Automatische Repository-Synchronisation</p>
</header>
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-value" id="totalRepos">0</div>
<div class="stat-label">Repositories</div>
</div>
<div class="stat-card">
<div class="stat-value" id="syncedRepos">0</div>
<div class="stat-label">Synchronisiert</div>
</div>
<div class="stat-card">
<div class="stat-value" id="errorRepos">0</div>
<div class="stat-label">Fehler</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalLogs">0</div>
<div class="stat-label">Log-Einträge (24h)</div>
</div>
</div>
<section class="section">
<div class="section-header">
<h2>Repositories</h2>
<button class="btn btn-primary" onclick="showAddRepoModal()">+ Repository hinzufügen</button>
</div>
<div id="reposList" class="repos-list">
<div class="loading">Lade Repositories...</div>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Letzte Ereignisse</h2>
<button class="btn btn-secondary" onclick="refreshLogs()">🔄 Aktualisieren</button>
</div>
<div id="logsList" class="logs-list">
<div class="loading">Lade Logs...</div>
</div>
</section>
</div>
<!-- Modal: Add Repository -->
<div id="addRepoModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Repository hinzufügen</h2>
<button class="close-btn" onclick="closeModal('addRepoModal')">&times;</button>
</div>
<div class="modal-body">
<form id="addRepoForm" onsubmit="addRepository(event)">
<div class="form-group">
<label for="repoName">Name</label>
<input type="text" id="repoName" name="name" required placeholder="Mein Projekt">
</div>
<div class="form-group">
<label for="repoUrl">GitHub Repository URL</label>
<input type="url" id="repoUrl" name="repo_url" required
placeholder="https://github.com/user/repo.git"
onblur="fetchBranches()">
<small>Die HTTPS Clone URL des Repositories</small>
</div>
<div class="form-group">
<label for="repoBranch">Branch</label>
<select id="repoBranch" name="branch" required>
<option value="main">main</option>
<option value="master">master</option>
</select>
<small id="branchLoading" style="display:none;">Lade Branches...</small>
</div>
<div class="form-group">
<label for="targetPath">Ziel-Pfad auf Server</label>
<input type="text" id="targetPath" name="target_path" required
placeholder="/var/www/mein-projekt">
<small>Absoluter Pfad, wo das Repository geklont werden soll</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="auto_sync" checked>
Auto-Sync aktivieren (reagiert auf Webhooks)
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal('addRepoModal')">Abbrechen</button>
<button type="submit" class="btn btn-primary">Repository hinzufügen</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal: Rollback -->
<div id="rollbackModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Rollback durchführen</h2>
<button class="close-btn" onclick="closeModal('rollbackModal')">&times;</button>
</div>
<div class="modal-body">
<p>Wähle einen Commit aus, zu dem du zurückkehren möchtest:</p>
<div id="commitsList" class="commits-list">
<div class="loading">Lade Commits...</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal('rollbackModal')">Abbrechen</button>
</div>
</div>
</div>
</div>
<!-- Modal: Webhook Info -->
<div id="webhookModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Webhook-Konfiguration</h2>
<button class="close-btn" onclick="closeModal('webhookModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Payload URL</label>
<div class="input-with-copy">
<input type="text" id="webhookUrl" readonly>
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard('webhookUrl')">Kopieren</button>
</div>
</div>
<div class="form-group">
<label>Secret</label>
<div class="input-with-copy">
<input type="text" id="webhookSecret" readonly>
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard('webhookSecret')">Kopieren</button>
</div>
</div>
<div class="form-group">
<label>Content type</label>
<input type="text" value="application/json" readonly>
</div>
<div class="alert alert-info">
<strong>Einrichtung:</strong>
<ol>
<li>Gehe zu deinem GitHub Repository</li>
<li>Settings Webhooks Add webhook</li>
<li>Füge die obige Payload URL ein</li>
<li>Füge das Secret ein</li>
<li>Wähle "application/json" als Content type</li>
<li>Wähle "Just the push event"</li>
<li>Klicke auf "Add webhook"</li>
</ol>
</div>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toastContainer"></div>
<script src="js/app.js"></script>
</body>
</html>
+516
View File
@@ -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 = '<div class="empty-state">Keine Repositories konfiguriert. Füge dein erstes Repository hinzu!</div>';
return;
}
container.innerHTML = repositories.map(repo => `
<div class="repo-card status-${repo.status || 'pending'}">
<div class="repo-header">
<div>
<div class="repo-name">${escapeHtml(repo.name)}</div>
<div class="repo-url">${escapeHtml(repo.repo_url)}</div>
</div>
<span class="repo-status ${repo.status || 'pending'}">${getStatusText(repo.status)}</span>
</div>
<div class="repo-info">
<div class="info-item">
<span class="info-label">Branch</span>
<span class="info-value">${escapeHtml(repo.branch)}</span>
</div>
<div class="info-item">
<span class="info-label">Ziel-Pfad</span>
<span class="info-value">${escapeHtml(repo.target_path)}</span>
</div>
<div class="info-item">
<span class="info-label">Letzte Sync</span>
<span class="info-value">${repo.last_sync || 'Noch nie'}</span>
</div>
<div class="info-item">
<span class="info-label">Auto-Sync</span>
<span class="info-value">${repo.auto_sync ? '✅ Aktiv' : '❌ Inaktiv'}</span>
</div>
</div>
<div class="repo-actions">
<button class="btn btn-success btn-sm" onclick="syncRepository('${repo.id}')">
🔄 Manueller Sync
</button>
<button class="btn btn-secondary btn-sm" onclick="showRollbackModal('${repo.id}')">
⏪ Rollback
</button>
<button class="btn btn-secondary btn-sm" onclick="showWebhookInfo('${repo.id}')">
🔗 Webhook Info
</button>
<button class="btn btn-danger btn-sm" onclick="deleteRepository('${repo.id}', '${escapeHtml(repo.name)}')">
🗑️ Entfernen
</button>
</div>
</div>
`).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 = '<div class="empty-state">Noch keine Log-Einträge vorhanden.</div>';
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 `
<div class="log-entry ${log.type}">
<div class="log-timestamp">${log.timestamp}</div>
<div class="log-content">
<div class="log-message">
<strong>${escapeHtml(repoName)}</strong> - ${escapeHtml(log.message)}
</div>
${log.details && Object.keys(log.details).length > 0 ? `
<div class="log-details">${formatLogDetails(log.details)}</div>
` : ''}
</div>
</div>
`;
}).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 = '<div class="loading">Lade Commits...</div>';
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 => `
<div class="commit-item" onclick="performRollback('${commit.hash}')">
<div class="commit-hash">${commit.hash_short}</div>
<div class="commit-message">${escapeHtml(commit.message)}</div>
<div class="commit-meta">
${escapeHtml(commit.author_name)} - ${commit.timestamp}
</div>
</div>
`).join('');
} else {
commitsList.innerHTML = '<div class="empty-state">Keine Commits gefunden.</div>';
}
} catch (error) {
console.error('Error loading commits:', error);
commitsList.innerHTML = '<div class="empty-state">Fehler beim Laden der Commits.</div>';
}
}
/**
* 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');
});
}
});
+131
View File
@@ -0,0 +1,131 @@
<?php
/**
* GitHub Webhook Endpoint
* Receives push events from GitHub and triggers sync
*/
require_once '../src/ConfigManager.php';
require_once '../src/Logger.php';
require_once '../src/GitHandler.php';
// Set JSON response header
header('Content-Type: application/json');
// Only allow POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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
]);
}
+8
View File
@@ -0,0 +1,8 @@
# Deny all access to source directory
Require all denied
# Alternative for older Apache versions
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+199
View File
@@ -0,0 +1,199 @@
<?php
/**
* ConfigManager - Handles reading and writing JSON configuration files
*/
class ConfigManager {
private $dataDir;
public function __construct($dataDir = '/gitpusher/data') {
$this->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));
}
}
+399
View File
@@ -0,0 +1,399 @@
<?php
/**
* GitHandler - Handles all Git operations
*/
class GitHandler {
private $logger;
private $configManager;
public function __construct(Logger $logger, ConfigManager $configManager) {
$this->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;
}
}
+166
View File
@@ -0,0 +1,166 @@
<?php
/**
* Logger - Manages log entries for sync operations
*/
class Logger {
private $configManager;
private $maxLogEntries = 1000; // Keep last 1000 entries
public function __construct(ConfigManager $configManager) {
$this->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;
}
}