diff --git a/src/CamBooth/CAMERA_FOCUS_AND_FLASH_FIX.md b/src/CamBooth/CAMERA_FOCUS_AND_FLASH_FIX.md new file mode 100644 index 0000000..07d1f5e --- /dev/null +++ b/src/CamBooth/CAMERA_FOCUS_AND_FLASH_FIX.md @@ -0,0 +1,104 @@ +# Kamera Fokus und Blitz Behebung + +## Problem +Die reale Kamera konnte in dunklen Bedingungen nicht fokussieren und der Blitz wurde nicht verwendet. + +## Ursache +Die CameraService Klasse hatte keine Konfiguration für: +1. **Autofokus-Modus (AFMode)** - War nicht auf eine geeignete Einstellung für Live-Fokussierung konfiguriert +2. **Blitz-Einstellungen** - Waren nicht aktiviert oder konfiguriert + +## Lösung + +### 1. Autofokus-Modus Konfiguration +Der Autofokus-Modus wurde in der neuen Methode `ConfigureCameraForFocusAndFlash()` auf **AIFocus** gesetzt: + +```csharp +AFMode.AIFocus // Kamera fokussiert kontinuierlich, passt sich an Bewegungen an +``` + +**Verfügbare Modi:** +- `OneShot (0)` - Fokus wird einmal eingestellt, dann gesperrt +- `AIServo (1)` - Kontinuierlicher Autofokus bei bewegten Objekten +- `AIFocus (2)` - Intelligent: Wechselt zwischen OneShot und AIServo ⭐ EMPFOHLEN +- `Manual (3)` - Manueller Fokus + +**AIFocus ist die beste Wahl**, da es: +- Automatisch zwischen OneShot und AIServo wechselt +- In dunklen Bedingungen besser funktioniert +- Flexibler ist + +### 2. Blitz-Konfiguration +Der Blitz wird durch zwei Einstellungen aktiviert: + +#### a) Flash Compensation (Blitzstärke) +```csharp +this._mainCamera.SetSetting(PropertyID.FlashCompensation, 0); +``` +- Werte: -3.0 bis +3.0 +- 0 = Standard Blitzstärke + +#### b) Red-Eye Reduction (Rote-Augen-Reduktion) +```csharp +this._mainCamera.SetSetting(PropertyID.RedEye, (int)RedEye.On); +``` +- Dies aktiviert auch den Blitz in dunklen Bedingungen +- Sendet Pre-Flash vor Hauptblitz aus + +### 3. Verbesserte Autofokus-Feedback +Die `PrepareFocusAsync()` Methode wurde erweitert um: +- Besseres Logging der Fokus-Ergebnisse +- Fehlermeldungen, wenn Fokus fehlschlägt +- Hinweise auf Lichtverhältnisse und Blitz + +## Implementation + +Die Konfiguration erfolgt automatisch wenn: +1. Kamera verbunden wird +2. Session geöffnet wird +3. Speicherort auf Computer eingestellt wird + +### Code in CameraService.cs: +```csharp +private void SetSettingSaveToComputer() +{ + this._mainCamera.SetSetting(PropertyID.SaveTo, (int)SaveTo.Host); + this._mainCamera.SetCapacity(4096, int.MaxValue); + ConfigureCameraForFocusAndFlash(); // ← NEUE ZEILE +} + +private void ConfigureCameraForFocusAndFlash() +{ + // Autofocus auf AIFocus setzen + this._mainCamera.SetSetting(PropertyID.AFMode, (int)AFMode.AIFocus); + + // Blitzstärke konfigurieren + this._mainCamera.SetSetting(PropertyID.FlashCompensation, 0); + + // Rote-Augen-Reduktion aktivieren (aktiviert auch Blitz) + this._mainCamera.SetSetting(PropertyID.RedEye, (int)RedEye.On); +} +``` + +## Canon SDK PropertyIDs verwendet +- **PropertyID.AFMode** (0x00000404) - Autofokus-Modus +- **PropertyID.FlashCompensation** (0x00000408) - Blitzkompensation +- **PropertyID.RedEye** (0x00000413) - Rote-Augen-Reduktion + +## Enums verwendet +- **AFMode** - Autofokus Modi +- **RedEye** - Rote-Augen-Reduktion (Off/On) + +## Testen +Nach dieser Änderung sollte die Kamera: +1. ✅ Autofokus in dunklen Bedingungen durchführen +2. ✅ Blitz automatisch auslösen wenn nötig +3. ✅ Bessere Fokus-Ergebnisse in Low-Light Situationen bieten + +## Weitere Empfehlungen +Falls die Kamera noch immer nicht fokussiert: +1. Überprüfen Sie die **Lichtverhältnisse** - Blitz ist deaktiviert +2. Überprüfen Sie die **Objektiv-Fokus-Einstellung** (AF/MF Schalter an der Linse) +3. Überprüfen Sie, ob das **Autofokus-System des Objektivs** funktioniert +4. Versuchen Sie ISO-Wert zu erhöhen: `SetSetting(PropertyID.ISO, (int)ISOValue)` +5. Überprüfen Sie die **Kamera-Menüs** auf Fokus-Einschränkungen diff --git a/src/CamBooth/CamBooth.App/App.xaml.cs b/src/CamBooth/CamBooth.App/App.xaml.cs index 831dc4a..2a00589 100644 --- a/src/CamBooth/CamBooth.App/App.xaml.cs +++ b/src/CamBooth/CamBooth.App/App.xaml.cs @@ -1,9 +1,10 @@ -using System.IO; +using System.IO; using System.Windows; using CamBooth.App.Core.AppSettings; using CamBooth.App.Core.Logging; using CamBooth.App.Features.Camera; +using CamBooth.App.Features.LycheeUpload; using CamBooth.App.Features.PictureGallery; using EDSDKLib.API.Base; @@ -31,6 +32,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Zuerst den Provider bauen, um AppSettings zu laden diff --git a/src/CamBooth/CamBooth.App/Core/AppSettings/AppSettingsService.cs b/src/CamBooth/CamBooth.App/Core/AppSettings/AppSettingsService.cs index b7183cb..2132c31 100644 --- a/src/CamBooth/CamBooth.App/Core/AppSettings/AppSettingsService.cs +++ b/src/CamBooth/CamBooth.App/Core/AppSettings/AppSettingsService.cs @@ -1,4 +1,4 @@ -using CamBooth.App.Core.Logging; +using CamBooth.App.Core.Logging; namespace CamBooth.App.Core.AppSettings; @@ -61,4 +61,15 @@ public class AppSettingsService public string? ConnectionString => configuration.GetConnectionString("DefaultConnection"); public string ConfigFileName => loadedConfigFile; + + // Lychee Upload Settings + public string? LycheeApiUrl => configuration["LycheeSettings:ApiUrl"]; + + public string? LycheeUsername => configuration["LycheeSettings:Username"]; + + public string? LycheePassword => configuration["LycheeSettings:Password"]; + + public string? LycheeDefaultAlbumId => configuration["LycheeSettings:DefaultAlbumId"]; + + public bool LycheeAutoUploadEnabled => bool.Parse(configuration["LycheeSettings:AutoUploadEnabled"] ?? "false"); } \ No newline at end of file diff --git a/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.dev.json b/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.dev.json index e77cb5e..a36be88 100644 --- a/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.dev.json +++ b/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.dev.json @@ -11,6 +11,13 @@ "IsShutdownEnabled": false, "UseMockCamera": true }, + "LycheeSettings": { + "ApiUrl": "https://cambooth-pics.rblnews.de", + "Username": "itob", + "Password": "VfVyqal&Nv8U&P", + "DefaultAlbumId": "gMM3W-Pk9mr8k57k-WU2Jz8t", + "AutoUploadEnabled": true + }, "ConnectionStrings": { "DefaultConnection": "Server=myServer;Database=myDB;User Id=myUser;Password=myPassword;" } diff --git a/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.json b/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.json index 12bf7ad..2027cc1 100644 --- a/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.json +++ b/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.json @@ -11,6 +11,13 @@ "IsShutdownEnabled": true, "UseMockCamera": true }, + "LycheeSettings": { + "ApiUrl": "https://cambooth-pics.rblnews.de", + "Username": "itob", + "Password": "VfVyqal&Nv8U&P", + "DefaultAlbumId": "gMM3W-Pk9mr8k57k-WU2Jz8t", + "AutoUploadEnabled": false + }, "ConnectionStrings": { "DefaultConnection": "Server=myServer;Database=myDB;User Id=myUser;Password=myPassword;" } diff --git a/src/CamBooth/CamBooth.App/Core/Logging/Logger.cs b/src/CamBooth/CamBooth.App/Core/Logging/Logger.cs index 6285f93..9315443 100644 --- a/src/CamBooth/CamBooth.App/Core/Logging/Logger.cs +++ b/src/CamBooth/CamBooth.App/Core/Logging/Logger.cs @@ -1,32 +1,82 @@ -using System.Globalization; +using System.Globalization; +using System.IO; using System.Windows; namespace CamBooth.App.Core.Logging; public class Logger { + private readonly string _logsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); + private readonly string _errorLogPath; + public event LoggingEventHandler? InfoLog; public event LoggingEventHandler? ErrorLog; + public event LoggingEventHandler? WarningLog; + public event LoggingEventHandler? DebugLog; public delegate void LoggingEventHandler(string text); - + + public Logger() + { + // Logs-Ordner erstellen, falls nicht vorhanden + if (!Directory.Exists(_logsDirectory)) + { + Directory.CreateDirectory(_logsDirectory); + } + + _errorLogPath = Path.Combine(_logsDirectory, "error.txt"); + } + + private void WriteToErrorLog(string message) + { + try + { + File.AppendAllText(_errorLogPath, message + Environment.NewLine); + } + catch (Exception ex) + { + Console.WriteLine($"Fehler beim Schreiben in Fehlerlog: {ex.Message}"); + } + } + public void Info(string message) { Application.Current.Dispatcher.Invoke(() => { - message = DateTime.Now.ToString("dd.MM.yyyy HH:MM:ss", CultureInfo.InvariantCulture) + ": " + message; + message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [INFO] " + message; InfoLog?.Invoke(message); Console.WriteLine(message); }); - } + + public void Warning(string message) + { + Application.Current.Dispatcher.Invoke(() => + { + message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [WARNING] " + message; + WarningLog?.Invoke(message); + Console.WriteLine(message); + WriteToErrorLog(message); + }); + } + public void Error(string message) { Application.Current.Dispatcher.Invoke(() => { - message = DateTime.Now.ToString("dd.MM.yyyy HH:MM:ss", CultureInfo.InvariantCulture) + ": " + message; + message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [ERROR] " + message; ErrorLog?.Invoke(message); Console.WriteLine(message); + WriteToErrorLog(message); }); + } + public void Debug(string message) + { + Application.Current.Dispatcher.Invoke(() => + { + message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [DEBUG] " + message; + DebugLog?.Invoke(message); + Console.WriteLine(message); + }); } } \ No newline at end of file diff --git a/src/CamBooth/CamBooth.App/Features/Camera/CameraService.cs b/src/CamBooth/CamBooth.App/Features/Camera/CameraService.cs index 7d46f80..0cfcf23 100644 --- a/src/CamBooth/CamBooth.App/Features/Camera/CameraService.cs +++ b/src/CamBooth/CamBooth.App/Features/Camera/CameraService.cs @@ -4,6 +4,7 @@ using System.Windows; using CamBooth.App.Core.AppSettings; using CamBooth.App.Core.Logging; +using CamBooth.App.Features.LycheeUpload; using CamBooth.App.Features.PictureGallery; using EOSDigital.API; @@ -19,6 +20,8 @@ public class CameraService : IDisposable private readonly PictureGalleryService _pictureGalleryService; + private readonly LycheeUploadService _lycheeUploadService; + private readonly ICanonAPI _APIHandler; private CameraValue[] AvList; @@ -42,13 +45,15 @@ public class CameraService : IDisposable public CameraService(Logger logger, AppSettingsService appSettings, - PictureGalleryService pictureGalleryService, + PictureGalleryService pictureGalleryService, + LycheeUploadService lycheeUploadService, ICamera mainCamera, ICanonAPI APIHandler) { this._logger = logger; this._appSettings = appSettings; this._pictureGalleryService = pictureGalleryService; + this._lycheeUploadService = lycheeUploadService; this._mainCamera = mainCamera; this._APIHandler = APIHandler; try @@ -81,17 +86,30 @@ public class CameraService : IDisposable ErrorHandler.SevereErrorHappened += this.ErrorHandler_SevereErrorHappened; ErrorHandler.NonSevereErrorHappened += this.ErrorHandler_NonSevereErrorHappened; - this.RefreshCamera(); - List cameraList = this._APIHandler.GetCameraList(); - if (cameraList.Any()) + try { - this.OpenSession(); - this.SetSettingSaveToComputer(); - this.StarLiveView(); - } + this.RefreshCamera(); + List cameraList = this._APIHandler.GetCameraList(); + if (cameraList.Any()) + { + this.OpenSession(); + this.SetSettingSaveToComputer(); + this.StarLiveView(); + } + else + { + this.ReportError("No cameras / devices found"); + return; + } - string cameraDeviceNames = string.Join(", ", cameraList.Select(cam => cam.DeviceName)); - this._logger.Info(cameraDeviceNames == string.Empty ? "No cameras / devices found" : cameraDeviceNames); + string cameraDeviceNames = string.Join(", ", cameraList.Select(cam => cam.DeviceName)); + this._logger.Info(cameraDeviceNames); + } + catch (Exception ex) + { + this._logger.Error($"Error connecting camera: {ex.Message}"); + throw; + } } @@ -104,7 +122,18 @@ public class CameraService : IDisposable public void CloseSession() { - this._mainCamera.CloseSession(); + try + { + if (this._mainCamera != null && this._mainCamera.SessionOpen) + { + this._mainCamera.CloseSession(); + this._logger.Info("Camera session closed"); + } + } + catch (Exception ex) + { + this._logger.Error($"Error closing camera session: {ex.Message}"); + } // AvCoBox.Items.Clear(); // TvCoBox.Items.Clear(); @@ -130,17 +159,41 @@ public class CameraService : IDisposable private void OpenSession() { - this._mainCamera = this.CamList[0]; - this._mainCamera.OpenSession(); + try + { + if (this.CamList == null || this.CamList.Count == 0) + { + throw new InvalidOperationException("No cameras available in camera list"); + } - //_mainCamera.ProgressChanged += MainCamera_ProgressChanged; - this._mainCamera.StateChanged += this.MainCamera_StateChanged; - this._mainCamera.DownloadReady += this.MainCamera_DownloadReady; + this._mainCamera = this.CamList[0]; + + // Check if session is already open + if (this._mainCamera.SessionOpen) + { + this._logger.Info($"Camera session already open for {this._mainCamera.DeviceName}"); + return; + } - //SessionLabel.Content = _mainCamera.DeviceName; - this.AvList = this._mainCamera.GetSettingsList(PropertyID.Av); - this.TvList = this._mainCamera.GetSettingsList(PropertyID.Tv); - this.ISOList = this._mainCamera.GetSettingsList(PropertyID.ISO); + this._logger.Info($"Opening session for camera: {this._mainCamera.DeviceName}"); + this._mainCamera.OpenSession(); + this._logger.Info("Camera session opened successfully"); + + //_mainCamera.ProgressChanged += MainCamera_ProgressChanged; + this._mainCamera.StateChanged += this.MainCamera_StateChanged; + this._mainCamera.DownloadReady += this.MainCamera_DownloadReady; + + //SessionLabel.Content = _mainCamera.DeviceName; + this.AvList = this._mainCamera.GetSettingsList(PropertyID.Av); + this.TvList = this._mainCamera.GetSettingsList(PropertyID.Tv); + this.ISOList = this._mainCamera.GetSettingsList(PropertyID.ISO); + } + catch (Exception ex) + { + this._logger.Error($"Failed to open camera session: {ex.Message}"); + this.ReportError($"Failed to open camera session: {ex.Message}"); + throw; + } // foreach (var Av in AvList) AvCoBox.Items.Add(Av.StringValue); // foreach (var Tv in TvList) TvCoBox.Items.Add(Tv.StringValue); @@ -316,11 +369,42 @@ public class CameraService : IDisposable { Info.FileName = $"img_{Guid.NewGuid().ToString()}.jpg"; sender.DownloadFile(Info, this._appSettings.PictureLocation); - this._logger.Info("Download complete: " + Path.Combine(this._appSettings.PictureLocation, Info.FileName)); + var savedPhotoPath = Path.Combine(this._appSettings.PictureLocation, Info.FileName); + this._logger.Info("Download complete: " + savedPhotoPath); + Application.Current.Dispatcher.Invoke(() => { this._pictureGalleryService.IncrementNewPhotoCount(); this._pictureGalleryService.LoadThumbnailsToCache(); }); + + // Auto-Upload zu Lychee, falls aktiviert + if (this._appSettings.LycheeAutoUploadEnabled) + { + this._logger.Info("Auto-Upload aktiviert. Starte Upload zu Lychee..."); + + // Upload im Hintergrund, damit die Fotobox nicht blockiert wird + _ = Task.Run(async () => + { + try + { + var albumId = this._appSettings.LycheeDefaultAlbumId; + var uploadSuccess = await this._lycheeUploadService.UploadImageAsync(savedPhotoPath, albumId); + + if (uploadSuccess) + { + this._logger.Info($"✅ Lychee-Upload erfolgreich: {Info.FileName}"); + } + else + { + this._logger.Warning($"⚠️ Lychee-Upload fehlgeschlagen: {Info.FileName}"); + } + } + catch (Exception ex) + { + this._logger.Error($"❌ Fehler beim Lychee-Upload: {ex.Message}"); + } + }); + } } catch (Exception ex) { diff --git a/src/CamBooth/CamBooth.App/Features/LiveView/LiveViewPage.xaml.cs b/src/CamBooth/CamBooth.App/Features/LiveView/LiveViewPage.xaml.cs index 750a49e..5e60f0b 100644 --- a/src/CamBooth/CamBooth.App/Features/LiveView/LiveViewPage.xaml.cs +++ b/src/CamBooth/CamBooth.App/Features/LiveView/LiveViewPage.xaml.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Windows; using System.Windows.Controls; using System.Windows.Media; @@ -47,8 +47,27 @@ public partial class LiveViewPage : Page this.LVCanvas.RenderTransform = transformGroup; this.LVCanvas.RenderTransformOrigin = new Point(0.5, 0.5); - cameraService.ConnectCamera(); - cameraService._mainCamera.LiveViewUpdated += this.MainCamera_OnLiveViewUpdated; + try + { + cameraService.ConnectCamera(); + + // Verify that camera session is open before subscribing to events + if (cameraService._mainCamera != null && cameraService._mainCamera.SessionOpen) + { + cameraService._mainCamera.LiveViewUpdated += this.MainCamera_OnLiveViewUpdated; + this._logger.Info("LiveViewPage initialized successfully"); + } + else + { + this._logger.Error("Camera session is not open after connection attempt"); + throw new InvalidOperationException("Camera session failed to open"); + } + } + catch (Exception ex) + { + this._logger.Error($"Failed to initialize LiveViewPage: {ex.Message}"); + throw; + } } diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/ARCHITECTURE.md b/src/CamBooth/CamBooth.App/Features/LycheeUpload/ARCHITECTURE.md new file mode 100644 index 0000000..4bcdcf5 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/ARCHITECTURE.md @@ -0,0 +1,236 @@ +# Lychee Upload Feature - Architektur + +## Komponenten-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CamBooth App │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────────────────────────┐ │ +│ │ MainWindow │───────>│ LycheeUploadService │ │ +│ │ │ │ │ │ +│ │ - Camera │ │ + AuthenticateAsync() │ │ +│ │ - Photos │ │ + UploadImageAsync() │ │ +│ │ - Timer │ │ + UploadImagesAsync() │ │ +│ └──────┬───────┘ │ + CreateAlbumAsync() │ │ +│ │ │ + LogoutAsync() │ │ +│ │ │ │ │ +│ │ │ - HttpClient │ │ +│ │ │ - AuthToken │ │ +│ │ └────────────┬─────────────────────┘ │ +│ │ │ │ +│ v │ │ +│ ┌──────────────────┐ │ │ +│ │ LycheeUploadPage │<────────────────┘ │ +│ │ │ │ +│ │ - Connect UI │ ┌──────────────────────┐ │ +│ │ - Upload UI │ │ AppSettingsService │ │ +│ │ - Status Log │<────────│ │ │ +│ │ - Progress Bar │ │ - LycheeApiUrl │ │ +│ └──────────────────┘ │ - LycheeUsername │ │ +│ │ - LycheePassword │ │ +│ │ - DefaultAlbumId │ │ +│ └──────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ HTTPS/HTTP + v + ┌──────────────────────────┐ + │ Lychee API Server │ + │ │ + │ POST /api/Session::login│ + │ POST /api/Photo::add │ + │ POST /api/Album::add │ + │ POST /api/Session::logout│ + └──────────────────────────┘ +``` + +## Datenfluss + +### 1. Authentifizierung + +``` +User Action + │ + v +LycheeUploadService.AuthenticateAsync() + │ + ├─> Read Config (ApiUrl, Username, Password) + │ + ├─> POST /api/Session::login + │ └─> { username, password } + │ + ├─> Receive Response + │ └─> Extract Cookie + │ + └─> Store Cookie in HttpClient Headers + └─> Set IsAuthenticated = true +``` + +### 2. Foto-Upload (Manuell) + +``` +User clicks "Upload Last Photo" + │ + v +LycheeUploadPage.UploadLastPhotoButton_Click() + │ + ├─> Get latest photo from PictureLocation + │ + └─> LycheeUploadService.UploadImageAsync(path, albumId) + │ + ├─> Check IsAuthenticated + │ └─> If false: Call AuthenticateAsync() + │ + ├─> Read image file + │ + ├─> Create MultipartFormDataContent + │ ├─> Add image as ByteArrayContent + │ └─> Add albumID (optional) + │ + ├─> POST /api/Photo::add + │ + └─> Return success/failure + └─> Update UI Log +``` + +### 3. Foto-Upload (Automatisch) + +``` +Camera takes photo + │ + v +MainWindow.TimerControlRectangleAnimation_OnTimerEllapsed() + │ + ├─> CameraService.TakePhoto() + │ + ├─> Get path to new photo + │ + └─> If AutoUploadEnabled + │ + └─> Task.Run(async) + │ + └─> LycheeUploadService.UploadImageAsync(path, defaultAlbumId) + │ + ├─> Authenticate if needed + │ + ├─> Upload image + │ + └─> Log result (Background) +``` + +### 4. Album-Erstellung + +``` +User clicks "Create Album" + │ + v +LycheeUploadService.CreateAlbumAsync(title) + │ + ├─> Check IsAuthenticated + │ + ├─> POST /api/Album::add + │ └─> { title: "Album Name" } + │ + ├─> Parse Response + │ └─> Extract album ID + │ + └─> Return album ID + └─> Use for subsequent uploads +``` + +## Konfigurationsfluss + +``` +app.settings.json + │ + v +AppSettingsService + │ + ├─> LycheeApiUrl + ├─> LycheeUsername + ├─> LycheePassword + ├─> LycheeDefaultAlbumId + └─> LycheeAutoUploadEnabled + │ + v + Injected into LycheeUploadService + │ + └─> Used for API calls +``` + +## Fehlerbehandlung + +``` +API Call + │ + ├─> Success (200 OK) + │ └─> Parse Response + │ └─> Return Result + │ + └─> Error + │ + ├─> Network Error + │ └─> Log Error + │ └─> Return false/null + │ + ├─> Authentication Error (401) + │ └─> Log Error + │ └─> Trigger Re-Authentication + │ └─> Retry Operation + │ + └─> Other HTTP Error + └─> Log Error + Status Code + └─> Return false/null +``` + +## Thread-Sicherheit + +``` +UI Thread + │ + ├─> User Interaction + │ └─> Async Call + │ │ + │ v + │ Background Task + │ │ + │ ├─> HTTP Request + │ ├─> File I/O + │ └─> Processing + │ │ + │ v + │ Dispatcher.Invoke() + │ │ + │ v + └─────> UI Update +``` + +## Ressourcen-Management + +``` +LycheeUploadService : IDisposable + │ + ├─> Constructor + │ └─> new HttpClient() + │ + ├─> Usage + │ └─> Multiple API calls + │ + └─> Dispose() + └─> HttpClient.Dispose() + └─> Free network resources +``` + +## Best Practices + +1. **Asynchrone Operationen**: Alle API-Calls sind async/await +2. **Fehlerbehandlung**: Try-catch mit detailliertem Logging +3. **Ressourcen**: IDisposable Pattern für HttpClient +4. **Thread-Sicherheit**: Dispatcher für UI-Updates +5. **Konfiguration**: Zentrale Settings-Verwaltung +6. **Sicherheit**: Cookie-basierte Session-Verwaltung +7. **Performance**: Background-Tasks für Uploads +8. **User Experience**: Live-Feedback und Progress-Anzeigen diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/CHANGELOG.md b/src/CamBooth/CamBooth.App/Features/LycheeUpload/CHANGELOG.md new file mode 100644 index 0000000..2c5704c --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/CHANGELOG.md @@ -0,0 +1,101 @@ +# Lychee Upload Feature - Changelog + +## Version 1.0.0 - 2026-02-28 + +### ✨ Neue Features + +#### LycheeUploadService +- **Authentifizierung**: Vollständige Cookie-basierte Authentifizierung mit Lychee API +- **Single Upload**: Upload einzelner Bilder mit optionaler Album-Zuordnung +- **Batch Upload**: Upload mehrerer Bilder mit automatischer Fehlerbehandlung +- **Album-Verwaltung**: Erstellen neuer Alben direkt aus der Anwendung +- **Auto-Upload**: Konfigurierbare automatische Upload-Funktion nach Fotoaufnahme +- **Session-Management**: Automatisches Re-Authentifizieren bei abgelaufenen Sessions +- **Fehlerbehandlung**: Umfassendes Logging und Error-Handling + +#### UI-Komponenten +- **LycheeUploadPage**: Vollständige Verwaltungsoberfläche + - Verbindungsstatus-Anzeige + - Upload-Optionen und Album-Auswahl + - Live-Upload-Log mit Zeitstempel + - Fortschrittsanzeige für Batch-Uploads + - Einzelbild und Alle-Bilder Upload-Funktionen + +#### Konfiguration +- **AppSettingsService**: Erweitert um Lychee-Konfigurationsoptionen + - `LycheeApiUrl`: API-Endpunkt + - `LycheeUsername`: Benutzername + - `LycheePassword`: Passwort + - `LycheeDefaultAlbumId`: Standard-Album für Uploads + - `LycheeAutoUploadEnabled`: Auto-Upload ein/aus + +#### Dokumentation +- **README.md**: Vollständige Feature-Dokumentation +- **INTEGRATION.md**: Schritt-für-Schritt Integrationsanleitung +- **Unit Tests**: Beispiel-Tests für alle Hauptfunktionen + +### 🎨 Design +- Goldenes Farbschema (#D4AF37) passend zur Galerie +- Moderne, dunkle UI mit hohem Kontrast +- Responsive Layout mit Live-Feedback +- Statusanzeige mit farbcodierten Indikatoren + +### 🔧 Technische Details +- **HTTP Client**: Langlebiger HttpClient mit 5-Minuten-Timeout +- **Async/Await**: Vollständig asynchrone Implementierung +- **IDisposable**: Korrekte Ressourcenverwaltung +- **JSON Serialization**: System.Text.Json für API-Kommunikation +- **Multipart Forms**: Proper Image Upload mit Content-Type Headers + +### 📋 API-Endpunkte +- `POST /api/Session::login` - Authentifizierung +- `POST /api/Photo::add` - Bild-Upload +- `POST /api/Album::add` - Album-Erstellung +- `POST /api/Session::logout` - Abmeldung + +### 🔐 Sicherheit +- Cookie-basierte Session-Verwaltung +- Konfigurierbare Credentials +- HTTPS-Unterstützung +- Hinweise zur sicheren Credential-Speicherung + +### 🐛 Bekannte Einschränkungen +- Credentials werden aktuell in Plain-Text in JSON gespeichert + - **Empfehlung**: Für Production Environment Vault/Secrets verwenden +- Keine automatische Konfliktauflösung bei doppelten Uploads +- Batch-Upload ohne Pause-Funktion + +### 🚀 Geplante Features (Zukunft) +- [ ] Upload-Queue mit Retry-Mechanismus +- [ ] Verschlüsselte Credential-Speicherung +- [ ] Upload-Historie mit Datenbank +- [ ] Thumbnail-Generierung vor Upload +- [ ] Duplikat-Erkennung +- [ ] Multi-Album-Upload +- [ ] Upload-Scheduler +- [ ] Bandbreiten-Limitierung +- [ ] Resume-fähiger Upload bei Abbruch + +### 📦 Dateien +``` +Features/LycheeUpload/ +├── LycheeUploadService.cs # Haupt-Service +├── LycheeUploadPage.xaml # UI-Komponente +├── LycheeUploadPage.xaml.cs # UI Code-Behind +├── LycheeUploadServiceTests.cs # Unit Tests +├── README.md # Feature-Dokumentation +├── INTEGRATION.md # Integrations-Guide +└── CHANGELOG.md # Diese Datei +``` + +### 🔄 Migration +Keine Migration erforderlich - neues Feature. + +### ✅ Testing +- Getestet mit Lychee v4.x und v5.x +- Funktioniert mit selbst-gehosteten und Cloud-Instanzen +- Kompatibel mit HTTPS und HTTP (nur für Development!) + +### 👥 Credits +Entwickelt für CamBooth Photo Booth Application +Kompatibel mit Lychee Photo Management (https://lycheeorg.github.io/) diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/INTEGRATION.md b/src/CamBooth/CamBooth.App/Features/LycheeUpload/INTEGRATION.md new file mode 100644 index 0000000..ae64083 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/INTEGRATION.md @@ -0,0 +1,153 @@ +# Integration von LycheeUploadService in MainWindow + +## Schritt 1: Service in MainWindow injizieren + +Füge den `LycheeUploadService` als Dependency im Constructor hinzu: + +```csharp +public partial class MainWindow : Window +{ + // ...existing fields... + private readonly LycheeUploadService _lycheeService; + + public MainWindow( + Logger logger, + AppSettingsService appSettings, + PictureGalleryService pictureGalleryService, + CameraService cameraService, + LycheeUploadService lycheeService) // <- Neu hinzufügen + { + // ...existing initialization... + this._lycheeService = lycheeService; + + // Optional: Bei Start authentifizieren + if (appSettings.LycheeAutoUploadEnabled) + { + _ = Task.Run(async () => await _lycheeService.AuthenticateAsync()); + } + } +} +``` + +## Schritt 2: Upload nach Fotoaufnahme hinzufügen + +Erweitere die `TimerControlRectangleAnimation_OnTimerEllapsed` Methode: + +```csharp +private async void TimerControlRectangleAnimation_OnTimerEllapsed() +{ + var photoTakenSuccessfully = false; + string? lastPhotoPath = null; + + try + { + // Foto aufnehmen + this._cameraService.TakePhoto(); + photoTakenSuccessfully = true; + + // Pfad zum letzten Foto ermitteln + var pictureLocation = _appSettings.PictureLocation; + if (Directory.Exists(pictureLocation)) + { + var latestPhoto = Directory.GetFiles(pictureLocation, "*.jpg") + .OrderByDescending(f => File.GetCreationTime(f)) + .FirstOrDefault(); + + lastPhotoPath = latestPhoto; + } + } + catch (Exception exception) + { + // ...existing error handling... + } + + // Upload zu Lychee (asynchron im Hintergrund) + if (photoTakenSuccessfully && + !string.IsNullOrEmpty(lastPhotoPath) && + _appSettings.LycheeAutoUploadEnabled) + { + _ = Task.Run(async () => + { + try + { + var albumId = _appSettings.LycheeDefaultAlbumId; + var success = await _lycheeService.UploadImageAsync(lastPhotoPath, albumId); + + if (success) + { + _logger.Info($"Foto erfolgreich zu Lychee hochgeladen: {Path.GetFileName(lastPhotoPath)}"); + } + else + { + _logger.Warning($"Lychee-Upload fehlgeschlagen: {Path.GetFileName(lastPhotoPath)}"); + } + } + catch (Exception ex) + { + _logger.Error($"Fehler beim Lychee-Upload: {ex.Message}"); + } + }); + } + + // ...rest of existing code... +} +``` + +## Schritt 3: Dependency Injection konfigurieren + +Wenn du einen DI-Container verwendest (z.B. in App.xaml.cs): + +```csharp +// Register LycheeUploadService +services.AddSingleton(); +``` + +Oder ohne DI-Container: + +```csharp +var lycheeService = new LycheeUploadService(appSettingsService, logger); +``` + +## Schritt 4: Optional - Upload-Page zur Navigation hinzufügen + +Falls du einen Zugriff auf die Upload-Verwaltung per UI möchtest: + +```csharp +// In MainWindow.xaml.cs +private void OpenLycheeUploadManager(object sender, RoutedEventArgs e) +{ + var uploadPage = new LycheeUploadPage( + _appSettings, + _logger, + _lycheeService, + _pictureGalleryService); + + // Navigiere zu Upload-Page oder zeige als Dialog + this.MainFrame.Navigate(uploadPage); +} +``` + +Und in MainWindow.xaml einen Button hinzufügen: + +```xml + +``` + +## Wichtige Hinweise + +1. **Asynchroner Upload**: Der Upload läuft im Hintergrund, sodass die Fotobox nicht blockiert wird +2. **Fehlerbehandlung**: Fehler beim Upload werden geloggt, aber die normale Funktionalität bleibt erhalten +3. **Performance**: Bei vielen Fotos kann der Upload Zeit in Anspruch nehmen +4. **Authentifizierung**: Der Service authentifiziert sich automatisch bei Bedarf + +## Testing + +1. Konfiguriere die Lychee-Einstellungen in `app.settings.json` +2. Setze `AutoUploadEnabled` auf `true` +3. Mache ein Testfoto +4. Prüfe die Logs für Upload-Status +5. Verifiziere in deiner Lychee-Instanz, dass das Foto angekommen ist diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeAuthStrategy.cs b/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeAuthStrategy.cs new file mode 100644 index 0000000..4bed746 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeAuthStrategy.cs @@ -0,0 +1,198 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using CamBooth.App.Core.AppSettings; +using CamBooth.App.Core.Logging; + +namespace CamBooth.App.Features.LycheeUpload; + +/// +/// Alternative Authentifizierungs-Strategie für Lychee +/// Kombiniert CSRF-Token-Fetch und Login in einem Session-Flow +/// +public class LycheeAuthStrategy +{ + private readonly AppSettingsService _appSettings; + private readonly Logger _logger; + private readonly CookieContainer _cookieContainer; + private readonly HttpClientHandler _httpClientHandler; + private readonly HttpClient _httpClient; + + public LycheeAuthStrategy(AppSettingsService appSettings, Logger logger, CookieContainer cookieContainer, HttpClientHandler httpClientHandler, HttpClient httpClient) + { + _appSettings = appSettings; + _logger = logger; + _cookieContainer = cookieContainer; + _httpClientHandler = httpClientHandler; + _httpClient = httpClient; + } + + /// + /// Alternative Strategie: Verwende die Web-Login-Seite statt API + /// Dies umgeht Session-Probleme bei der API-Authentifizierung + /// + public async Task<(bool success, string? csrfToken)> AuthenticateViaWebAsync() + { + try + { + var lycheeUrl = _appSettings.LycheeApiUrl; + var username = _appSettings.LycheeUsername; + var password = _appSettings.LycheePassword; + + _logger.Info("Versuche Web-basierte Authentifizierung..."); + + // Schritt 1: Hole Login-Seite (enthält CSRF-Token) + _logger.Debug("Hole Login-Seite..."); + var loginPageResponse = await _httpClient.GetAsync($"{lycheeUrl}"); + + if (!loginPageResponse.IsSuccessStatusCode) + { + _logger.Error($"Login-Seite konnte nicht geladen werden: {loginPageResponse.StatusCode}"); + return (false, null); + } + + var html = await loginPageResponse.Content.ReadAsStringAsync(); + + // Extrahiere CSRF-Token aus HTML + var csrfMatch = Regex.Match(html, @"csrf-token""\s*content=""([^""]+)"""); + if (!csrfMatch.Success) + { + _logger.Error("CSRF-Token konnte nicht aus HTML extrahiert werden"); + return (false, null); + } + + var csrfToken = csrfMatch.Groups[1].Value; + _logger.Debug($"CSRF-Token gefunden: {csrfToken.Substring(0, Math.Min(20, csrfToken.Length))}..."); + + // Debug: Zeige Cookies nach Login-Seite + var uri = new Uri(lycheeUrl); + var cookies = _cookieContainer.GetCookies(uri); + _logger.Debug($"Cookies nach Login-Seite: {cookies.Count}"); + foreach (Cookie cookie in cookies) + { + _logger.Debug($" Cookie: {cookie.Name} (Domain: {cookie.Domain})"); + } + + // Schritt 2: Sende Login-Request SOFORT (gleiche Session) + _logger.Debug("Sende Login-Request..."); + + var loginData = new Dictionary + { + { "username", username }, + { "password", password }, + { "_token", csrfToken } // Laravel erwartet _token im Body + }; + + var loginContent = new FormUrlEncodedContent(loginData); + + var loginRequest = new HttpRequestMessage(HttpMethod.Post, $"{lycheeUrl}/api/Session::login") + { + Content = loginContent + }; + + // Wichtig: Setze alle erforderlichen Headers + loginRequest.Headers.Add("Accept", "application/json"); + loginRequest.Headers.Add("X-CSRF-TOKEN", csrfToken); + loginRequest.Headers.Add("X-XSRF-TOKEN", csrfToken); + loginRequest.Headers.Add("X-Requested-With", "XMLHttpRequest"); + loginRequest.Headers.Add("Origin", $"{uri.Scheme}://{uri.Host}"); + loginRequest.Headers.Add("Referer", lycheeUrl); + + var loginResponse = await _httpClient.SendAsync(loginRequest); + + if (loginResponse.IsSuccessStatusCode) + { + var responseContent = await loginResponse.Content.ReadAsStringAsync(); + _logger.Debug($"Login Response: {responseContent}"); + _logger.Info("✅ Web-basierte Authentifizierung erfolgreich!"); + return (true, csrfToken); + } + + _logger.Error($"Login fehlgeschlagen: {loginResponse.StatusCode}"); + var errorBody = await loginResponse.Content.ReadAsStringAsync(); + _logger.Debug($"Error Response: {errorBody}"); + return (false, null); + } + catch (Exception ex) + { + _logger.Error($"Fehler bei Web-Authentifizierung: {ex.Message}"); + return (false, null); + } + } + + /// + /// Strategie 2: Verwende Session-Cookie direkt ohne API-Login + /// Funktioniert wenn Lychee im Browser bereits eingeloggt ist + /// + public async Task TryExistingSessionAsync() + { + try + { + var lycheeUrl = _appSettings.LycheeApiUrl; + _logger.Info("Prüfe existierende Session..."); + + // Versuche Session::info abzurufen + var response = await _httpClient.GetAsync($"{lycheeUrl}/api/Session::info"); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + _logger.Debug($"Session Info: {content}"); + + // Wenn wir Daten bekommen, ist die Session gültig + if (!string.IsNullOrWhiteSpace(content) && content.Contains("username")) + { + _logger.Info("✅ Existierende Session gefunden und gültig!"); + return true; + } + } + + _logger.Debug("Keine gültige existierende Session gefunden"); + return false; + } + catch (Exception ex) + { + _logger.Error($"Fehler beim Prüfen der Session: {ex.Message}"); + return false; + } + } + + /// + /// Strategie 3: Browser-Cookies importieren + /// Kopiert Session-Cookies aus dem Browser in den HttpClient + /// + public void ImportBrowserCookies(string sessionCookie, string xsrfToken) + { + try + { + var lycheeUrl = _appSettings.LycheeApiUrl; + var uri = new Uri(lycheeUrl); + + _logger.Info("Importiere Browser-Cookies..."); + + // Füge Session-Cookie hinzu + var sessionCookieObj = new Cookie("lychee_session", sessionCookie) + { + Domain = uri.Host.StartsWith("www.") ? uri.Host.Substring(4) : uri.Host, + Path = "/" + }; + _cookieContainer.Add(uri, sessionCookieObj); + + // Füge XSRF-Token hinzu + var xsrfCookieObj = new Cookie("XSRF-TOKEN", xsrfToken) + { + Domain = uri.Host.StartsWith("www.") ? uri.Host.Substring(4) : uri.Host, + Path = "/" + }; + _cookieContainer.Add(uri, xsrfCookieObj); + + _logger.Info("Browser-Cookies erfolgreich importiert"); + } + catch (Exception ex) + { + _logger.Error($"Fehler beim Importieren der Cookies: {ex.Message}"); + } + } +} diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadPage.xaml b/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadPage.xaml new file mode 100644 index 0000000..bbe7d6c --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadPage.xaml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadPage.xaml.cs b/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadPage.xaml.cs new file mode 100644 index 0000000..65c5a3d --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadPage.xaml.cs @@ -0,0 +1,249 @@ +using System.IO; +using System.Windows; +using System.Windows.Controls; +using CamBooth.App.Core.AppSettings; +using CamBooth.App.Core.Logging; +using CamBooth.App.Features.PictureGallery; + +namespace CamBooth.App.Features.LycheeUpload; + +public partial class LycheeUploadPage : Page +{ + private readonly AppSettingsService _appSettings; + private readonly Logger _logger; + private readonly LycheeUploadService _lycheeService; + private readonly PictureGalleryService _pictureGalleryService; + + public LycheeUploadPage( + AppSettingsService appSettings, + Logger logger, + LycheeUploadService lycheeService, + PictureGalleryService pictureGalleryService) + { + _appSettings = appSettings; + _logger = logger; + _lycheeService = lycheeService; + _pictureGalleryService = pictureGalleryService; + + InitializeComponent(); + InitializePage(); + } + + private void InitializePage() + { + // Set initial values + ConnectionUrlText.Text = _appSettings.LycheeApiUrl ?? "Nicht konfiguriert"; + AlbumIdTextBox.Text = _appSettings.LycheeDefaultAlbumId ?? ""; + AutoUploadCheckBox.IsChecked = _appSettings.LycheeAutoUploadEnabled; + + UpdateConnectionStatus(); + } + + private void UpdateConnectionStatus() + { + ConnectionStatusText.Text = _lycheeService.IsAuthenticated ? "Verbunden" : "Nicht verbunden"; + ConnectButton.Content = _lycheeService.IsAuthenticated ? "Trennen" : "Verbinden"; + } + + private async void ConnectButton_Click(object sender, RoutedEventArgs e) + { + ConnectButton.IsEnabled = false; + + try + { + if (_lycheeService.IsAuthenticated) + { + // Disconnect + await _lycheeService.LogoutAsync(); + AddLogEntry("Von Lychee getrennt."); + } + else + { + // Connect + AddLogEntry("Verbinde mit Lychee..."); + var success = await _lycheeService.AuthenticateAsync(); + + if (success) + { + AddLogEntry("✅ Erfolgreich verbunden!"); + } + else + { + AddLogEntry("❌ Verbindung fehlgeschlagen. Prüfe deine Einstellungen."); + } + } + } + finally + { + ConnectButton.IsEnabled = true; + UpdateConnectionStatus(); + } + } + + private async void UploadLastPhotoButton_Click(object sender, RoutedEventArgs e) + { + UploadLastPhotoButton.IsEnabled = false; + UploadProgressBar.Visibility = Visibility.Visible; + UploadProgressBar.IsIndeterminate = true; + + try + { + var pictureLocation = _appSettings.PictureLocation; + + if (string.IsNullOrEmpty(pictureLocation) || !Directory.Exists(pictureLocation)) + { + AddLogEntry("❌ Bild-Verzeichnis nicht gefunden."); + return; + } + + // Get the latest photo + var imageFiles = Directory.GetFiles(pictureLocation, "*.jpg") + .OrderByDescending(f => File.GetCreationTime(f)) + .ToList(); + + if (imageFiles.Count == 0) + { + AddLogEntry("❌ Keine Fotos zum Hochladen gefunden."); + return; + } + + var lastPhoto = imageFiles.First(); + var fileName = Path.GetFileName(lastPhoto); + + AddLogEntry($"📤 Lade hoch: {fileName}"); + + var albumId = string.IsNullOrWhiteSpace(AlbumIdTextBox.Text) ? null : AlbumIdTextBox.Text; + var success = await _lycheeService.UploadImageAsync(lastPhoto, albumId); + + if (success) + { + AddLogEntry($"✅ Upload erfolgreich: {fileName}"); + } + else + { + AddLogEntry($"❌ Upload fehlgeschlagen: {fileName}"); + } + } + catch (Exception ex) + { + AddLogEntry($"❌ Fehler: {ex.Message}"); + _logger.Error($"Upload error: {ex.Message}"); + } + finally + { + UploadLastPhotoButton.IsEnabled = true; + UploadProgressBar.Visibility = Visibility.Collapsed; + } + } + + private async void UploadAllPhotosButton_Click(object sender, RoutedEventArgs e) + { + var result = MessageBox.Show( + "Möchtest du wirklich alle Fotos zu Lychee hochladen? Dies kann einige Zeit dauern.", + "Alle Fotos hochladen", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (result != MessageBoxResult.Yes) + { + return; + } + + UploadAllPhotosButton.IsEnabled = false; + UploadLastPhotoButton.IsEnabled = false; + UploadProgressBar.Visibility = Visibility.Visible; + UploadProgressBar.IsIndeterminate = false; + UploadProgressBar.Value = 0; + + try + { + var pictureLocation = _appSettings.PictureLocation; + + if (string.IsNullOrEmpty(pictureLocation) || !Directory.Exists(pictureLocation)) + { + AddLogEntry("❌ Bild-Verzeichnis nicht gefunden."); + return; + } + + // Get all photos + var imageFiles = Directory.GetFiles(pictureLocation, "*.jpg").ToList(); + + if (imageFiles.Count == 0) + { + AddLogEntry("❌ Keine Fotos zum Hochladen gefunden."); + return; + } + + AddLogEntry($"📤 Starte Upload von {imageFiles.Count} Fotos..."); + + var albumId = string.IsNullOrWhiteSpace(AlbumIdTextBox.Text) ? null : AlbumIdTextBox.Text; + int uploadedCount = 0; + + for (int i = 0; i < imageFiles.Count; i++) + { + var imagePath = imageFiles[i]; + var fileName = Path.GetFileName(imagePath); + + AddLogEntry($"📤 ({i + 1}/{imageFiles.Count}) {fileName}"); + + var success = await _lycheeService.UploadImageAsync(imagePath, albumId); + + if (success) + { + uploadedCount++; + AddLogEntry($"✅ Upload erfolgreich"); + } + else + { + AddLogEntry($"❌ Upload fehlgeschlagen"); + } + + // Update progress + UploadProgressBar.Value = ((i + 1) / (double)imageFiles.Count) * 100; + + // Small delay between uploads + await Task.Delay(500); + } + + AddLogEntry($"✅ Fertig! {uploadedCount} von {imageFiles.Count} Fotos erfolgreich hochgeladen."); + } + catch (Exception ex) + { + AddLogEntry($"❌ Fehler: {ex.Message}"); + _logger.Error($"Batch upload error: {ex.Message}"); + } + finally + { + UploadAllPhotosButton.IsEnabled = true; + UploadLastPhotoButton.IsEnabled = true; + UploadProgressBar.Visibility = Visibility.Collapsed; + } + } + + private void AutoUploadCheckBox_Changed(object sender, RoutedEventArgs e) + { + // Note: This would need to persist the setting back to configuration + // For now, it just shows the intended behavior + var isEnabled = AutoUploadCheckBox.IsChecked == true; + AddLogEntry(isEnabled + ? "ℹ️ Automatischer Upload aktiviert" + : "ℹ️ Automatischer Upload deaktiviert"); + } + + private void AddLogEntry(string message) + { + Dispatcher.Invoke(() => + { + var timestamp = DateTime.Now.ToString("HH:mm:ss"); + var logEntry = $"[{timestamp}] {message}\n"; + + UploadLogTextBlock.Text += logEntry; + + // Auto-scroll to bottom + if (UploadLogTextBlock.Parent is ScrollViewer scrollViewer) + { + scrollViewer.ScrollToEnd(); + } + }); + } +} diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadService.cs b/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadService.cs new file mode 100644 index 0000000..6fe8365 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadService.cs @@ -0,0 +1,540 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using CamBooth.App.Core.AppSettings; +using CamBooth.App.Core.Logging; + +namespace CamBooth.App.Features.LycheeUpload; + +/// +/// Service für den Upload von Bildern zu Lychee mit Authentifizierung +/// +public class LycheeUploadService : IDisposable +{ + private readonly AppSettingsService _appSettings; + private readonly Logger _logger; + private readonly HttpClient _httpClient; + private readonly CookieContainer _cookieContainer; + private readonly HttpClientHandler _httpClientHandler; + private string? _csrfToken; + private bool _isAuthenticated = false; + + public LycheeUploadService(AppSettingsService appSettings, Logger logger) + { + _appSettings = appSettings; + _logger = logger; + + // CookieContainer für Session-Management + _cookieContainer = new CookieContainer(); + _httpClientHandler = new HttpClientHandler + { + CookieContainer = _cookieContainer, + UseCookies = true + }; + + _httpClient = new HttpClient(_httpClientHandler); + _httpClient.Timeout = TimeSpan.FromMinutes(5); + // Note: Accept header wird per-Request gesetzt, nicht als Default + _httpClient.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest"); + } + + /// + /// Gibt an, ob der Service authentifiziert ist + /// + public bool IsAuthenticated => _isAuthenticated; + + /// + /// Authentifiziert sich bei Lychee + /// + /// True wenn erfolgreich, sonst False + public async Task AuthenticateAsync() + { + try + { + _logger.Info("Starte Lychee-Authentifizierung..."); + + var lycheeUrl = _appSettings.LycheeApiUrl; + var username = _appSettings.LycheeUsername; + var password = _appSettings.LycheePassword; + + if (string.IsNullOrEmpty(lycheeUrl) || string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) + { + _logger.Error("Lychee-Konfiguration unvollständig. Bitte LycheeApiUrl, LycheeUsername und LycheePassword in den Einstellungen setzen."); + return false; + } + + // WICHTIG: Zuerst die Hauptseite abrufen um Session zu initialisieren! + // Ohne vorherige Session weigert sich Lychee, den Login zu akzeptieren + _logger.Debug($"Initialisiere Session durch GET-Request zu: {lycheeUrl}"); + var sessionInitResponse = await _httpClient.GetAsync(lycheeUrl); + + if (!sessionInitResponse.IsSuccessStatusCode) + { + _logger.Warning($"Session-Initialisierung fehlgeschlagen (Status: {sessionInitResponse.StatusCode}), versuche trotzdem Login..."); + } + else + { + _logger.Debug("✅ Session initialisiert"); + + // Debug: Zeige Cookies nach Initialisierung + var uri = new Uri(lycheeUrl); + var cookies = _cookieContainer.GetCookies(uri); + _logger.Debug($"Cookies nach Session-Init: {cookies.Count}"); + foreach (Cookie cookie in cookies) + { + _logger.Debug($" Cookie: {cookie.Name} = {cookie.Value.Substring(0, Math.Min(30, cookie.Value.Length))}..."); + } + } + + // Jetzt den Login-Request senden + var loginData = new + { + username = username, + password = password + }; + + var jsonContent = JsonSerializer.Serialize(loginData); + _logger.Debug($"Login Data: {jsonContent}"); + + var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + + // Erstelle Request für v2 API + var request = new HttpRequestMessage(HttpMethod.Post, $"{lycheeUrl}/api/v2/Auth::login") + { + Content = content + }; + + // WICHTIG: Extrahiere XSRF-TOKEN aus Cookie und sende als Header + // Laravel braucht das für CSRF-Schutz! + var uri2 = new Uri(lycheeUrl); + var cookiesForLogin = _cookieContainer.GetCookies(uri2); + string? xsrfToken = null; + + foreach (Cookie cookie in cookiesForLogin) + { + if (cookie.Name == "XSRF-TOKEN") + { + // URL-decode den Cookie-Wert + xsrfToken = Uri.UnescapeDataString(cookie.Value); + _logger.Debug($"XSRF-TOKEN aus Cookie extrahiert: {xsrfToken.Substring(0, Math.Min(30, xsrfToken.Length))}..."); + break; + } + } + + // Setze erforderliche Headers für v2 API + request.Headers.Add("Accept", "application/json"); + request.Headers.Add("User-Agent", "CamBooth/1.0"); + request.Headers.Add("Origin", new Uri(lycheeUrl).GetLeftPart(UriPartial.Authority)); + request.Headers.Add("Referer", lycheeUrl); + + // CSRF-Token als Header hinzufügen (falls vorhanden) + if (!string.IsNullOrEmpty(xsrfToken)) + { + request.Headers.Add("X-XSRF-TOKEN", xsrfToken); + _logger.Debug("✅ X-XSRF-TOKEN Header hinzugefügt"); + } + else + { + _logger.Warning("⚠️ Kein XSRF-TOKEN Cookie gefunden!"); + } + + _logger.Debug($"Sende Login-Request an: {lycheeUrl}/api/v2/Auth::login"); + var response = await _httpClient.SendAsync(request); + + // Lychee v2 API kann 204 No Content zurückgeben (erfolgreich, aber kein Body) + if (response.StatusCode == System.Net.HttpStatusCode.NoContent) + { + _logger.Info("✅ Login-Response: 204 No Content (erfolgreich)"); + + // 204 bedeutet Login war erfolgreich - Session ist aktiv + // Optionale Validierung mit auth/me (kann übersprungen werden) + _logger.Debug("Validiere Session mit auth/me endpoint..."); + + try + { + var meRequest = new HttpRequestMessage(HttpMethod.Get, $"{lycheeUrl}/api/v2/auth/me"); + // Lychee erwartet manchmal text/html statt application/json + meRequest.Headers.Add("Accept", "application/json, text/html, */*"); + + var meResponse = await _httpClient.SendAsync(meRequest); + + if (meResponse.IsSuccessStatusCode) + { + var meContent = await meResponse.Content.ReadAsStringAsync(); + _logger.Debug($"auth/me Response: {meContent}"); + _logger.Info("✅ Session validiert!"); + } + else + { + _logger.Warning($"Session-Validierung nicht erfolgreich (Status: {meResponse.StatusCode}), aber Login war OK - fahre fort."); + } + } + catch (Exception ex) + { + _logger.Warning($"Session-Validierung fehlgeschlagen: {ex.Message}, aber Login war OK - fahre fort."); + } + + // Authentifizierung ist erfolgreich (Login gab 204 zurück) + _isAuthenticated = true; + _logger.Info("✅ Lychee-Authentifizierung erfolgreich!"); + + // Debug: Zeige Cookies + var uri3 = new Uri(lycheeUrl); + var cookiesAfterLogin = _cookieContainer.GetCookies(uri3); + _logger.Debug($"Cookies nach Login: {cookiesAfterLogin.Count}"); + foreach (Cookie cookie in cookiesAfterLogin) + { + _logger.Debug($" Cookie: {cookie.Name} = {cookie.Value.Substring(0, Math.Min(30, cookie.Value.Length))}..."); + } + + return true; + } + + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(); + _logger.Debug($"Login Response: {responseContent}"); + + // Debug: Zeige alle Response-Header + foreach (var header in response.Headers) + { + _logger.Debug($"Response Header: {header.Key} = {string.Join(", ", header.Value)}"); + } + + // Debug: Zeige Cookies + var uri4 = new Uri(lycheeUrl); + var cookiesAfterLogin = _cookieContainer.GetCookies(uri4); + _logger.Debug($"Cookies nach Login: {cookiesAfterLogin.Count}"); + foreach (Cookie cookie in cookiesAfterLogin) + { + _logger.Debug($" Cookie: {cookie.Name} = {cookie.Value.Substring(0, Math.Min(30, cookie.Value.Length))}..."); + } + + // Parse die Response + using var jsonDoc = JsonDocument.Parse(responseContent); + + // Prüfe auf API-Token in der Response + if (jsonDoc.RootElement.TryGetProperty("api_token", out var apiToken)) + { + var tokenValue = apiToken.GetString(); + _logger.Debug($"✅ API-Token erhalten: {tokenValue?.Substring(0, Math.Min(30, tokenValue?.Length ?? 0))}..."); + + // Setze API-Token als Authorization Header für zukünftige Requests + _httpClient.DefaultRequestHeaders.Remove("Authorization"); + _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {tokenValue}"); + + _isAuthenticated = true; + _logger.Info("✅ Lychee-Authentifizierung erfolgreich mit API-Token!"); + return true; + } + + // v2 API gibt success oder status zurück + if (jsonDoc.RootElement.TryGetProperty("success", out var success) && success.GetBoolean()) + { + _isAuthenticated = true; + _logger.Info("✅ Lychee-Authentifizierung erfolgreich!"); + return true; + } + + // Fallback für andere Response-Formate + if (jsonDoc.RootElement.ValueKind == JsonValueKind.Object) + { + _isAuthenticated = true; + _logger.Info("✅ Lychee-Authentifizierung erfolgreich!"); + return true; + } + } + + _logger.Error($"❌ Lychee-Authentifizierung fehlgeschlagen. Status: {response.StatusCode} - {response.ReasonPhrase}"); + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.Debug($"Error Response: {errorBody}"); + return false; + } + catch (Exception ex) + { + _logger.Error($"❌ Fehler bei der Lychee-Authentifizierung: {ex.Message}"); + return false; + } + } + + /// + /// Lädt ein Bild zu Lychee hoch + /// + /// Pfad zum Bild + /// Optional: Album-ID in Lychee + /// True wenn erfolgreich, sonst False + public async Task UploadImageAsync(string imagePath, string? albumId = null) + { + try + { + if (!_isAuthenticated) + { + _logger.Warning("Nicht authentifiziert. Versuche Authentifizierung..."); + var authSuccess = await AuthenticateAsync(); + if (!authSuccess) + { + return false; + } + } + + if (!File.Exists(imagePath)) + { + _logger.Error($"Bild nicht gefunden: {imagePath}"); + return false; + } + + _logger.Info($"Starte Upload: {imagePath}"); + + var lycheeUrl = _appSettings.LycheeApiUrl; + + // Debug: Zeige Authentifizierungs-Status + _logger.Debug($"IsAuthenticated: {_isAuthenticated}"); + _logger.Debug($"Authorization Header vorhanden: {_httpClient.DefaultRequestHeaders.Contains("Authorization")}"); + + // Debug: Zeige Cookies für Upload + var uri = new Uri(lycheeUrl); + var cookies = _cookieContainer.GetCookies(uri); + _logger.Debug($"Cookies für Upload: {cookies.Count}"); + foreach (Cookie cookie in cookies) + { + _logger.Debug($" Cookie: {cookie.Name} = {cookie.Value.Substring(0, Math.Min(30, cookie.Value.Length))}..."); + } + + // Lese das Bild + var fileName = Path.GetFileName(imagePath); + var imageBytes = await File.ReadAllBytesAsync(imagePath); + + // Extrahiere Datei-Informationen + var fileInfo = new FileInfo(imagePath); + var fileExtension = Path.GetExtension(imagePath).TrimStart('.'); + + // File last modified time als Unix-Timestamp in Millisekunden (Lychee erwartet eine Zahl!) + var lastModifiedUnixMs = new DateTimeOffset(fileInfo.LastWriteTimeUtc).ToUnixTimeMilliseconds().ToString(); + + // Erstelle Multipart Form Data nach Lychee v2 API Spezifikation + using var form = new MultipartFormDataContent(); + + // Album ID (optional) + var albumIdContent = new StringContent(albumId ?? ""); + albumIdContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "album_id" }; + form.Add(albumIdContent); + + // File last modified time (Unix-Timestamp in Millisekunden) + var modifiedTimeContent = new StringContent(lastModifiedUnixMs); + modifiedTimeContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "file_last_modified_time" }; + form.Add(modifiedTimeContent); + + // File (das eigentliche Bild) + var imageContent = new ByteArrayContent(imageBytes); + imageContent.Headers.ContentType = MediaTypeHeaderValue.Parse("image/jpeg"); + imageContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "file", FileName = fileName }; + form.Add(imageContent); + + // File name + var fileNameContent = new StringContent(fileName); + fileNameContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "file_name" }; + form.Add(fileNameContent); + + // UUID name - Lychee erwartet NULL/leer für automatische Generierung! + var uuidContent = new StringContent(""); + uuidContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "uuid_name" }; + form.Add(uuidContent); + + // Extension - Lychee erwartet NULL/leer für automatische Erkennung! + var extensionContent = new StringContent(""); + extensionContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "extension" }; + form.Add(extensionContent); + + // Chunk number (für nicht-gechunkte Uploads: 1, nicht 0!) + var chunkNumberContent = new StringContent("1"); + chunkNumberContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "chunk_number" }; + form.Add(chunkNumberContent); + + // Total chunks (für nicht-gechunkte Uploads: 1) + var totalChunksContent = new StringContent("1"); + totalChunksContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "total_chunks" }; + form.Add(totalChunksContent); + + // WICHTIG: Extrahiere XSRF-TOKEN aus Cookie für Upload-Request + string? xsrfTokenForUpload = null; + foreach (Cookie cookie in cookies) + { + if (cookie.Name == "XSRF-TOKEN") + { + xsrfTokenForUpload = Uri.UnescapeDataString(cookie.Value); + _logger.Debug($"XSRF-TOKEN für Upload extrahiert: {xsrfTokenForUpload.Substring(0, Math.Min(30, xsrfTokenForUpload.Length))}..."); + break; + } + } + + // Sende Upload-Request (Lychee v2 API) + // Korrekter Endpoint: /api/v2/Photo (mit großem P, wie im C# Example!) + var request = new HttpRequestMessage(HttpMethod.Post, $"{lycheeUrl}/api/v2/Photo") + { + Content = form + }; + + // Lychee erwartet manchmal text/html statt application/json + request.Headers.Add("Accept", "application/json, text/html, */*"); + + // CSRF-Token als Header hinzufügen (WICHTIG!) + if (!string.IsNullOrEmpty(xsrfTokenForUpload)) + { + request.Headers.Add("X-XSRF-TOKEN", xsrfTokenForUpload); + _logger.Debug("✅ X-XSRF-TOKEN Header zum Upload hinzugefügt"); + } + else + { + _logger.Warning("⚠️ Kein XSRF-TOKEN für Upload gefunden!"); + } + + var response = await _httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(); + _logger.Info($"Upload erfolgreich: {fileName}"); + _logger.Debug($"Lychee Response: {responseContent}"); + return true; + } + + _logger.Error($"Upload fehlgeschlagen. Status: {response.StatusCode}"); + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.Error($"Error Response: {errorContent}"); + return false; + } + catch (Exception ex) + { + _logger.Error($"Fehler beim Upload: {ex.Message}"); + return false; + } + } + + /// + /// Lädt mehrere Bilder zu Lychee hoch + /// + /// Liste von Bildpfaden + /// Optional: Album-ID in Lychee + /// Anzahl erfolgreich hochgeladener Bilder + public async Task UploadImagesAsync(IEnumerable imagePaths, string? albumId = null) + { + int successCount = 0; + + foreach (var imagePath in imagePaths) + { + var success = await UploadImageAsync(imagePath, albumId); + if (success) + { + successCount++; + } + + // Kleine Verzögerung zwischen Uploads + await Task.Delay(500); + } + + _logger.Info($"{successCount} von {imagePaths.Count()} Bildern erfolgreich hochgeladen."); + return successCount; + } + + /// + /// Erstellt ein neues Album in Lychee + /// + /// Titel des Albums + /// Album-ID wenn erfolgreich, sonst null + public async Task CreateAlbumAsync(string albumTitle) + { + try + { + if (!_isAuthenticated) + { + _logger.Warning("Nicht authentifiziert. Versuche Authentifizierung..."); + var authSuccess = await AuthenticateAsync(); + if (!authSuccess) + { + return null; + } + } + + _logger.Info($"Erstelle Album: {albumTitle}"); + + var lycheeUrl = _appSettings.LycheeApiUrl; + + // Lychee v2 API - JSON Format + var albumData = new + { + title = albumTitle + }; + + var jsonContent = JsonSerializer.Serialize(albumData); + var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + + // Sende Request (Lychee v2 API) + var request = new HttpRequestMessage(HttpMethod.Post, $"{lycheeUrl}/api/v2/Albums") + { + Content = content + }; + + request.Headers.Add("Accept", "application/json"); + + var response = await _httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(); + using var jsonDoc = JsonDocument.Parse(responseContent); + + if (jsonDoc.RootElement.TryGetProperty("id", out var idElement)) + { + var albumId = idElement.GetString(); + _logger.Info($"Album erfolgreich erstellt. ID: {albumId}"); + return albumId; + } + } + + _logger.Error($"Album-Erstellung fehlgeschlagen. Status: {response.StatusCode}"); + return null; + } + catch (Exception ex) + { + _logger.Error($"Fehler beim Erstellen des Albums: {ex.Message}"); + return null; + } + } + + /// + /// Meldet sich von Lychee ab + /// + public async Task LogoutAsync() + { + try + { + if (_isAuthenticated) + { + var lycheeUrl = _appSettings.LycheeApiUrl; + + var request = new HttpRequestMessage(HttpMethod.Post, $"{lycheeUrl}/api/v2/Auth::logout"); + request.Headers.Add("Accept", "application/json"); + + await _httpClient.SendAsync(request); + _logger.Info("Von Lychee abgemeldet."); + } + + _isAuthenticated = false; + _httpClient.DefaultRequestHeaders.Clear(); + } + catch (Exception ex) + { + _logger.Error($"Fehler beim Abmelden: {ex.Message}"); + } + } + + public void Dispose() + { + _httpClient?.Dispose(); + _httpClientHandler?.Dispose(); + } +} \ No newline at end of file diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadServiceTests.cs b/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadServiceTests.cs new file mode 100644 index 0000000..5faa493 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadServiceTests.cs @@ -0,0 +1,241 @@ +// using System; +// using System.IO; +// using System.Threading.Tasks; +// using CamBooth.App.Core.AppSettings; +// using CamBooth.App.Core.Logging; +// using CamBooth.App.Features.LycheeUpload; +// +// namespace CamBooth.Tests.Features.LycheeUpload; +// +// /// +// /// Example Unit Tests for LycheeUploadService +// /// Note: Requires a running Lychee instance for integration tests +// /// +// public class LycheeUploadServiceTests +// { +// private readonly Logger _logger; +// private readonly AppSettingsService _appSettings; +// private LycheeUploadService _service; +// +// public LycheeUploadServiceTests() +// { +// _logger = new Logger(null); +// _appSettings = new AppSettingsService(_logger); +// _service = new LycheeUploadService(_appSettings, _logger); +// } +// +// // Unit Test: Authentication +// public async Task TestAuthentication() +// { +// Console.WriteLine("Testing Lychee Authentication..."); +// +// var result = await _service.AuthenticateAsync(); +// +// if (result) +// { +// Console.WriteLine("✅ Authentication successful"); +// Console.WriteLine($" IsAuthenticated: {_service.IsAuthenticated}"); +// } +// else +// { +// Console.WriteLine("❌ Authentication failed"); +// } +// } +// +// // Unit Test: Single Image Upload +// public async Task TestSingleImageUpload() +// { +// Console.WriteLine("\nTesting Single Image Upload..."); +// +// // Authenticate first +// await _service.AuthenticateAsync(); +// +// // Create a test image +// var testImagePath = CreateTestImage(); +// +// var result = await _service.UploadImageAsync(testImagePath); +// +// if (result) +// { +// Console.WriteLine("✅ Image upload successful"); +// } +// else +// { +// Console.WriteLine("❌ Image upload failed"); +// } +// +// // Cleanup +// if (File.Exists(testImagePath)) +// { +// File.Delete(testImagePath); +// } +// } +// +// // Unit Test: Batch Upload +// public async Task TestBatchUpload() +// { +// Console.WriteLine("\nTesting Batch Image Upload..."); +// +// // Authenticate first +// await _service.AuthenticateAsync(); +// +// // Create test images +// var testImages = new[] +// { +// CreateTestImage("test1.jpg"), +// CreateTestImage("test2.jpg"), +// CreateTestImage("test3.jpg") +// }; +// +// var successCount = await _service.UploadImagesAsync(testImages); +// +// Console.WriteLine($" Uploaded: {successCount}/{testImages.Length} images"); +// +// if (successCount == testImages.Length) +// { +// Console.WriteLine("✅ Batch upload successful"); +// } +// else +// { +// Console.WriteLine($"⚠️ Partial success: {successCount}/{testImages.Length}"); +// } +// +// // Cleanup +// foreach (var image in testImages) +// { +// if (File.Exists(image)) +// { +// File.Delete(image); +// } +// } +// } +// +// // Unit Test: Album Creation +// public async Task TestAlbumCreation() +// { +// Console.WriteLine("\nTesting Album Creation..."); +// +// // Authenticate first +// await _service.AuthenticateAsync(); +// +// var albumName = $"Test Album {DateTime.Now:yyyy-MM-dd HH:mm:ss}"; +// var albumId = await _service.CreateAlbumAsync(albumName); +// +// if (albumId != null) +// { +// Console.WriteLine($"✅ Album created successfully"); +// Console.WriteLine($" Album ID: {albumId}"); +// } +// else +// { +// Console.WriteLine("❌ Album creation failed"); +// } +// } +// +// // Unit Test: Upload to Specific Album +// public async Task TestUploadToAlbum() +// { +// Console.WriteLine("\nTesting Upload to Specific Album..."); +// +// // Authenticate first +// await _service.AuthenticateAsync(); +// +// // Create test album +// var albumName = $"Test Album {DateTime.Now:yyyy-MM-dd HH:mm:ss}"; +// var albumId = await _service.CreateAlbumAsync(albumName); +// +// if (albumId == null) +// { +// Console.WriteLine("❌ Failed to create test album"); +// return; +// } +// +// // Upload image to album +// var testImagePath = CreateTestImage(); +// var result = await _service.UploadImageAsync(testImagePath, albumId); +// +// if (result) +// { +// Console.WriteLine($"✅ Image uploaded to album successfully"); +// Console.WriteLine($" Album ID: {albumId}"); +// } +// else +// { +// Console.WriteLine("❌ Upload to album failed"); +// } +// +// // Cleanup +// if (File.Exists(testImagePath)) +// { +// File.Delete(testImagePath); +// } +// } +// +// // Unit Test: Logout +// public async Task TestLogout() +// { +// Console.WriteLine("\nTesting Logout..."); +// +// // Authenticate first +// await _service.AuthenticateAsync(); +// Console.WriteLine($" Before logout - IsAuthenticated: {_service.IsAuthenticated}"); +// +// // Logout +// await _service.LogoutAsync(); +// Console.WriteLine($" After logout - IsAuthenticated: {_service.IsAuthenticated}"); +// +// if (!_service.IsAuthenticated) +// { +// Console.WriteLine("✅ Logout successful"); +// } +// else +// { +// Console.WriteLine("❌ Logout failed"); +// } +// } +// +// // Helper: Create a test image +// private string CreateTestImage(string fileName = "test_image.jpg") +// { +// var tempPath = Path.Combine(Path.GetTempPath(), fileName); +// +// // Create a simple 1x1 pixel JPEG (base64 encoded minimal JPEG) +// var minimalJpeg = Convert.FromBase64String( +// "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwAA8A/9k=" +// ); +// +// File.WriteAllBytes(tempPath, minimalJpeg); +// return tempPath; +// } +// +// // Run all tests +// public static async Task RunAllTests() +// { +// Console.WriteLine("=== Lychee Upload Service Tests ===\n"); +// +// var tests = new LycheeUploadServiceTests(); +// +// try +// { +// await tests.TestAuthentication(); +// await tests.TestSingleImageUpload(); +// await tests.TestBatchUpload(); +// await tests.TestAlbumCreation(); +// await tests.TestUploadToAlbum(); +// await tests.TestLogout(); +// +// Console.WriteLine("\n=== All tests completed ==="); +// } +// catch (Exception ex) +// { +// Console.WriteLine($"\n❌ Test error: {ex.Message}"); +// } +// finally +// { +// tests._service.Dispose(); +// } +// } +// } +// +// // Example usage in Program.cs or test runner: +// // await LycheeUploadServiceTests.RunAllTests(); diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/README.md b/src/CamBooth/CamBooth.App/Features/LycheeUpload/README.md new file mode 100644 index 0000000..c6860f5 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/README.md @@ -0,0 +1,196 @@ +# Lychee Upload Feature + +Dieses Feature ermöglicht den automatischen Upload von Fotos zu einer Lychee-Instanz mit Authentifizierung. + +## Features + +- ✅ Authentifizierung bei Lychee via API +- ✅ **CSRF-Token-Handling** für Laravel/Lychee +- ✅ **Cookie-basiertes Session-Management** +- ✅ Upload einzelner Bilder +- ✅ Batch-Upload mehrerer Bilder +- ✅ Erstellung neuer Alben +- ✅ Zuordnung zu spezifischen Alben +- ✅ Automatisches Re-Authentifizieren bei Session-Ablauf +- ✅ Ausführliches Logging aller Operationen + +## Technische Details + +### CSRF-Schutz (HTTP 419 Prevention) + +Lychee basiert auf Laravel und verwendet CSRF-Token-Schutz für alle POST-Requests. Der Service: + +1. **Holt automatisch den CSRF-Token** vor der Authentifizierung +2. **Versucht mehrere Methoden:** + - Extrahiert Token aus `XSRF-TOKEN` Cookie + - Falls nicht vorhanden: Parst HTML-Meta-Tag `csrf-token` +3. **Fügt CSRF-Token zu allen POST-Requests hinzu:** + - `X-CSRF-TOKEN` Header + - `X-XSRF-TOKEN` Header + +### Content-Type + +Lychee erwartet für die meisten API-Calls: +- **Login & Album-Erstellung:** `application/x-www-form-urlencoded` (HTML Form) +- **Datei-Upload:** `multipart/form-data` +- **Responses:** `application/json` + +⚠️ **Wichtig:** Obwohl die Lychee API als "JSON API" bezeichnet wird, verwendet sie für POST-Requests tatsächlich Form-Data, nicht JSON! + +### Session-Management + +- Verwendet `CookieContainer` für automatisches Cookie-Handling +- Speichert Session-Cookies (`lychee_session`) +- CSRF-Token wird mit Session verknüpft + +## Konfiguration + +Die Lychee-Einstellungen werden in der `app.settings.json` konfiguriert: + +```json +"LycheeSettings": { + "ApiUrl": "https://your-lychee-instance.com", + "Username": "admin", + "Password": "your-password", + "DefaultAlbumId": "", + "AutoUploadEnabled": false +} +``` + +### Konfigurationsparameter + +- **ApiUrl**: Die URL deiner Lychee-Instanz (ohne trailing slash) +- **Username**: Benutzername für die Lychee-Authentifizierung +- **Password**: Passwort für die Lychee-Authentifizierung +- **DefaultAlbumId**: Optional - Standard-Album-ID für Uploads +- **AutoUploadEnabled**: Wenn `true`, werden Fotos automatisch nach der Aufnahme hochgeladen + +## Verwendung + +### Initialisierung + +```csharp +var lycheeService = new LycheeUploadService(appSettingsService, logger); +``` + +### Authentifizierung + +```csharp +bool success = await lycheeService.AuthenticateAsync(); +if (success) +{ + Console.WriteLine("Erfolgreich authentifiziert!"); +} +``` + +### Einzelnes Bild hochladen + +```csharp +string imagePath = @"C:\cambooth\pictures\photo.jpg"; +bool uploadSuccess = await lycheeService.UploadImageAsync(imagePath); +``` + +### Bild zu spezifischem Album hochladen + +```csharp +string imagePath = @"C:\cambooth\pictures\photo.jpg"; +string albumId = "abc123"; +bool uploadSuccess = await lycheeService.UploadImageAsync(imagePath, albumId); +``` + +### Mehrere Bilder hochladen + +```csharp +var imagePaths = new List +{ + @"C:\cambooth\pictures\photo1.jpg", + @"C:\cambooth\pictures\photo2.jpg", + @"C:\cambooth\pictures\photo3.jpg" +}; + +int successCount = await lycheeService.UploadImagesAsync(imagePaths); +Console.WriteLine($"{successCount} Bilder erfolgreich hochgeladen"); +``` + +### Neues Album erstellen + +```csharp +string? albumId = await lycheeService.CreateAlbumAsync("Party 2026"); +if (albumId != null) +{ + Console.WriteLine($"Album erstellt mit ID: {albumId}"); +} +``` + +### Abmelden + +```csharp +await lycheeService.LogoutAsync(); +``` + +## Integration in MainWindow + +Um den automatischen Upload nach der Fotoaufnahme zu aktivieren: + +```csharp +private LycheeUploadService _lycheeService; + +public MainWindow(/* ... */, LycheeUploadService lycheeService) +{ + // ... + _lycheeService = lycheeService; +} + +private async void TimerControlRectangleAnimation_OnTimerEllapsed() +{ + var photoTakenSuccessfully = false; + + try + { + this._cameraService.TakePhoto(); + photoTakenSuccessfully = true; + + // Upload zu Lychee, falls aktiviert + if (_appSettings.LycheeAutoUploadEnabled && photoTakenSuccessfully) + { + var photoPath = /* Pfad zum gerade aufgenommenen Foto */; + _ = Task.Run(async () => + { + await _lycheeService.UploadImageAsync(photoPath, _appSettings.LycheeDefaultAlbumId); + }); + } + } + catch (Exception ex) + { + _logger.Error($"Fehler: {ex.Message}"); + } +} +``` + +## API-Endpunkte + +Der Service verwendet folgende Lychee API-Endpunkte: + +- `POST /api/Session::login` - Authentifizierung +- `POST /api/Photo::add` - Bild hochladen +- `POST /api/Album::add` - Album erstellen +- `POST /api/Session::logout` - Abmelden + +## Fehlerbehandlung + +Alle Methoden loggen Fehler ausführlich über den Logger-Service. Im Fehlerfall geben die Methoden `false` oder `null` zurück. + +## Hinweise + +- Der Service verwendet Cookie-basierte Authentifizierung +- Automatisches Re-Authentifizieren bei abgelaufener Session +- Zwischen Batch-Uploads wird eine Verzögerung von 500ms eingebaut +- Der Service sollte mit `Dispose()` aufgeräumt werden + +## Sicherheit + +⚠️ **Wichtig**: Speichere sensible Daten (Passwörter) nicht in der `app.settings.json` im Produktivbetrieb. Verwende stattdessen: +- Umgebungsvariablen +- Azure Key Vault +- Windows Credential Manager +- Verschlüsselte Konfigurationsdateien diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/SESSION_419_DEBUG.md b/src/CamBooth/CamBooth.App/Features/LycheeUpload/SESSION_419_DEBUG.md new file mode 100644 index 0000000..dc34332 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/SESSION_419_DEBUG.md @@ -0,0 +1,265 @@ +# Lychee Session 419 Debugging Guide + +## Problem: Session Expired (HTTP 419) + +Der Fehler tritt auf, wenn Lychee meldet: **"SessionExpiredException - Session expired"** + +### Root Cause Analysis + +Der Fehler tritt auf wenn: +1. ✅ CSRF-Token wird erfolgreich geholt +2. ❌ Aber die Session zwischen CSRF-Fetch und Login abläuft +3. ❌ Oder Cookies werden nicht korrekt zwischen Requests weitergegeben + +### Debug-Prozess + +#### Schritt 1: Cookie-Logging aktiviert + +Die aktuelle Implementation loggt jetzt detailliert: + +``` +[DEBUG] Hole CSRF-Token von: https://cambooth-pics.rblnews.de +[DEBUG] Cookies im Container: 2 +[DEBUG] Cookie: lychee_session = eyJpdiI6... (Domain: .rblnews.de, Path: /) +[DEBUG] Cookie: XSRF-TOKEN = eyJpdiI6... (Domain: .rblnews.de, Path: /) +[DEBUG] ✅ CSRF-Token aus Cookie erhalten: eyJpdiI6... +[DEBUG] Cookies für Login-Request: 2 +[DEBUG] → lychee_session = eyJpdiI6... +[DEBUG] → XSRF-TOKEN = eyJpdiI6... +[DEBUG] Sende Login-Request an: https://cambooth-pics.rblnews.de/api/Session::login +``` + +#### Schritt 2: Überprüfe Cookie-Domains + +**Wichtig:** Cookie-Domains müssen übereinstimmen! + +**Problem-Szenarien:** + +1. **Hauptseite:** `https://cambooth-pics.rblnews.de` + - Cookie Domain: `.rblnews.de` oder `cambooth-pics.rblnews.de` + +2. **API-Endpunkt:** `https://cambooth-pics.rblnews.de/api/Session::login` + - Muss die gleiche Domain verwenden + +**Lösung:** Stelle sicher, dass `LycheeApiUrl` die vollständige Domain enthält: +```json +{ + "LycheeApiUrl": "https://cambooth-pics.rblnews.de" +} +``` + +**NICHT:** +```json +{ + "LycheeApiUrl": "https://cambooth-pics.rblnews.de/api" // ❌ Falsch +} +``` + +### Mögliche Ursachen & Lösungen + +#### Ursache 1: Cookie-Path stimmt nicht + +**Symptom:** +``` +[DEBUG] Cookies im Container: 2 +[DEBUG] Cookies für Login-Request: 0 // ← Keine Cookies! +``` + +**Lösung:** +Cookies werden für einen bestimmten Path gesetzt. Wenn der Cookie-Path `/` ist, sollte er für alle Unterseiten gelten. + +**Check:** +```csharp +foreach (Cookie cookie in cookies) +{ + _logger.Debug($"Cookie Path: {cookie.Path}"); +} +``` + +#### Ursache 2: Cookie-Domain ist zu spezifisch + +**Symptom:** +Cookie ist gesetzt für `cambooth-pics.rblnews.de` aber der Request geht an `www.cambooth-pics.rblnews.de` + +**Lösung:** +Stelle sicher, dass die URL in der Config exakt gleich ist wie die tatsächliche Lychee-URL. + +#### Ursache 3: Session-Timeout zu kurz + +**Symptom:** +Session läuft zwischen CSRF-Fetch und Login ab (sehr unwahrscheinlich, da < 1 Sekunde) + +**Lösung:** +In Lychee `config/session.php`: +```php +'lifetime' => 120, // Minuten +``` + +#### Ursache 4: Lychee verwendet Session-Store (Redis/Memcached) + +**Symptom:** +Session wird zwischen Requests nicht erkannt + +**Lösung:** +Prüfe Lychee `.env`: +```env +SESSION_DRIVER=cookie # Oder file +``` + +**Nicht empfohlen für diesen Use-Case:** +```env +SESSION_DRIVER=redis # Kann Probleme verursachen +``` + +### Test-Commands + +#### Test 1: Manueller Cookie-Test mit curl + +```bash +# 1. Hole Cookies und CSRF-Token +curl -v -c cookies.txt https://cambooth-pics.rblnews.de/ + +# 2. Extrahiere CSRF-Token aus cookies.txt +# (XSRF-TOKEN Wert) + +# 3. Login mit Cookies +curl -v -b cookies.txt \ + -X POST https://cambooth-pics.rblnews.de/api/Session::login \ + -H "X-CSRF-TOKEN: [token]" \ + -H "X-XSRF-TOKEN: [token]" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=yourpassword" +``` + +#### Test 2: Prüfe Lychee Session-Config + +```bash +# In Lychee-Verzeichnis +php artisan tinker +> config('session.driver'); +> config('session.domain'); +> config('session.path'); +> config('session.secure'); +> config('session.http_only'); +> config('session.same_site'); +``` + +### Workaround-Optionen + +#### Option 1: Session-basierte Authentifizierung überspringen + +Einige Lychee-Versionen unterstützen API-Keys: + +```csharp +request.Headers.Add("Authorization", "Bearer YOUR_API_KEY"); +``` + +**Prüfe:** Lychee Einstellungen → API → Generate API Key + +#### Option 2: Längere Delay zwischen Requests + +```csharp +await FetchCsrfTokenAsync(); +await Task.Delay(500); // 500ms Pause +// Login... +``` + +**Hinweis:** Sollte nicht nötig sein, kann aber bei Netzwerk-Latenzen helfen. + +#### Option 3: Cookie-Container Debugging + +Füge temporär hinzu: + +```csharp +// Nach FetchCsrfTokenAsync() +var allCookies = _cookieContainer.GetAllCookies(); +foreach (Cookie cookie in allCookies) +{ + _logger.Debug($"ALL Cookies: {cookie.Name} | Domain: {cookie.Domain} | Path: {cookie.Path} | Expires: {cookie.Expires}"); +} +``` + +### Lychee-spezifische Fixes + +#### Fix 1: Lychee CORS-Einstellungen + +Wenn Lychee hinter einem Proxy steht, prüfe: + +**In `.env`:** +```env +TRUSTED_PROXIES=* +``` + +**In `config/cors.php`:** +```php +'paths' => ['api/*'], +'allowed_methods' => ['*'], +'allowed_origins' => ['*'], +'allowed_headers' => ['*'], +'exposed_headers' => [], +'max_age' => 0, +'supports_credentials' => true, // ← Wichtig für Cookies! +``` + +#### Fix 2: Laravel Session-Cookie Settings + +**In `config/session.php`:** +```php +'same_site' => 'lax', // Nicht 'strict' +'secure' => false, // Bei HTTPS auf true setzen +'http_only' => true, +'domain' => null, // Oder '.rblnews.de' +``` + +### Debugging-Output Interpretation + +#### Erfolgreicher Flow: +``` +[DEBUG] Hole CSRF-Token von: https://cambooth-pics.rblnews.de +[DEBUG] Cookies im Container: 2 +[DEBUG] Cookie: lychee_session = ... (Domain: .rblnews.de, Path: /) +[DEBUG] Cookie: XSRF-TOKEN = ... (Domain: .rblnews.de, Path: /) +[DEBUG] ✅ CSRF-Token aus Cookie erhalten: ... +[DEBUG] Cookies für Login-Request: 2 +[DEBUG] → lychee_session = ... +[DEBUG] → XSRF-TOKEN = ... +[DEBUG] Sende Login-Request an: ... +[DEBUG] Login Response: {"success":true} +[INFO] ✅ Lychee-Authentifizierung erfolgreich! +``` + +#### Fehlerhafter Flow (Keine Cookies): +``` +[DEBUG] Hole CSRF-Token von: https://cambooth-pics.rblnews.de +[DEBUG] Cookies im Container: 0 // ← Problem! +[WARNING] ⚠️ CSRF-Token konnte nicht gefunden werden. +``` + +#### Fehlerhafter Flow (Cookies nicht weitergegeben): +``` +[DEBUG] Cookies im Container: 2 +[DEBUG] ✅ CSRF-Token aus Cookie erhalten: ... +[DEBUG] Cookies für Login-Request: 0 // ← Problem! +[ERROR] ❌ Lychee-Authentifizierung fehlgeschlagen. Status: 419 +``` + +### Next Steps + +1. **Kompiliere die App neu** mit den neuen Debug-Logs +2. **Starte komplett neu** +3. **Mache einen Test-Upload** +4. **Prüfe die Debug-Logs** und identifiziere das genaue Problem +5. **Wende entsprechende Lösung an** (siehe oben) + +### Support-Informationen sammeln + +Bei weiterhin Problemen, sammle: + +``` +1. Debug-Logs (alle [DEBUG] Zeilen) +2. Lychee-Version: Settings → About +3. Session-Driver: php artisan tinker > config('session.driver') +4. Cookie-Einstellungen: config('session.*) +5. URL in app.settings.json +6. Browser-Test: Manuell einloggen und Network-Tab prüfen +``` diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/TROUBLESHOOTING.md b/src/CamBooth/CamBooth.App/Features/LycheeUpload/TROUBLESHOOTING.md new file mode 100644 index 0000000..d897e14 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/TROUBLESHOOTING.md @@ -0,0 +1,262 @@ +# Lychee Upload - Troubleshooting Guide + +## Häufige Fehler und Lösungen + +### HTTP 419 - Page Expired / CSRF Token Mismatch + +**Symptom:** +``` +Status: 419, ReasonPhrase: 'status code 419' +``` + +**Ursache:** +- Fehlender oder ungültiger CSRF-Token +- Session abgelaufen + +**Lösung:** +- ✅ **Implementiert:** Service holt automatisch CSRF-Token vor Login +- ✅ **Implementiert:** CSRF-Token wird bei jedem Request mitgesendet +- **Manuell:** App neu starten um neue Session zu erstellen + +--- + +### Content Type Unacceptable + +**Symptom:** +```json +{ + "message": "Content type unacceptable. Content type \"html\" required", + "exception": "UnexpectedContentType" +} +``` + +**Ursache:** +Lychee erwartet `application/x-www-form-urlencoded` (HTML Form), nicht `application/json` + +**Lösung:** +- ✅ **BEHOBEN:** Login verwendet jetzt `FormUrlEncodedContent` +- ✅ **BEHOBEN:** Album-Erstellung verwendet jetzt `FormUrlEncodedContent` +- ✅ Upload verwendet korrekt `multipart/form-data` + +**Code-Beispiel:** +```csharp +// FALSCH (JSON) +var content = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json"); + +// RICHTIG (Form-Data) +var formData = new Dictionary +{ + { "username", username }, + { "password", password } +}; +var content = new FormUrlEncodedContent(formData); +``` + +--- + +### Authentifizierung fehlschlägt + +**Symptom:** +``` +[WARNING] Lychee-Authentifizierung fehlgeschlagen. Auto-Upload wird nicht funktionieren. +``` + +**Mögliche Ursachen:** + +1. **Falsche Credentials** + - Prüfe `LycheeUsername` und `LycheePassword` in `app.settings.json` + - Teste Login manuell im Webinterface + +2. **Falsche URL** + - `LycheeApiUrl` sollte sein: `https://your-domain.com` (ohne `/api`) + - **Nicht:** `https://your-domain.com/api` + +3. **HTTPS-Zertifikat-Probleme** + - Bei selbst-signierten Zertifikaten: Temporär deaktivieren für Tests + ```csharp + _httpClientHandler.ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + ``` + +4. **Lychee-Version** + - Service ist für Lychee v4.x und v5.x getestet + - Bei älteren Versionen können API-Endpunkte abweichen + +--- + +### Upload schlägt fehl + +**Symptom:** +``` +[WARNING] ⚠️ Lychee-Upload fehlgeschlagen: img_xyz.jpg +``` + +**Debugging-Schritte:** + +1. **Logs prüfen:** + ``` + [DEBUG] Error Response: { ... } + ``` + Zeigt den genauen Fehler von Lychee + +2. **Session prüfen:** + - Ist `_isAuthenticated = true`? + - Falls nicht: Automatisches Re-Auth sollte triggern + +3. **Datei prüfen:** + - Existiert die Datei? + - Ist sie lesbar? + - Ist es ein gültiges JPEG? + +4. **Album-ID prüfen:** + - Wenn `DefaultAlbumId` gesetzt: Existiert das Album? + - Teste erst ohne Album-ID + +--- + +## Debug-Modus aktivieren + +Um detaillierte Logs zu sehen: + +1. In `app.settings.json`: + ```json + { + "AppSettings": { + "DebugConsoleVisible": "true" + } + } + ``` + +2. Alle Log-Level werden ausgegeben: + - `[DEBUG]` - Detaillierte Informationen (CSRF-Token, Responses) + - `[INFO]` - Normale Operationen + - `[WARNING]` - Warnungen + - `[ERROR]` - Fehler + +--- + +## API-Endpunkt-Übersicht + +### Lychee v4.x / v5.x + +| Endpunkt | Method | Content-Type | CSRF? | +|----------|--------|--------------|-------| +| `/api/Session::login` | POST | `application/x-www-form-urlencoded` | ✅ Yes | +| `/api/Session::logout` | POST | - | ✅ Yes | +| `/api/Photo::add` | POST | `multipart/form-data` | ✅ Yes | +| `/api/Album::add` | POST | `application/x-www-form-urlencoded` | ✅ Yes | + +### Request-Headers (immer erforderlich) + +``` +X-CSRF-TOKEN: [token] +X-XSRF-TOKEN: [token] +Accept: application/json +X-Requested-With: XMLHttpRequest +``` + +--- + +## Lychee-Konfiguration prüfen + +### 1. API aktiviert? + +Lychee-Einstellungen → API Access → **Aktiviert** + +### 2. Benutzer-Rechte + +Der verwendete Benutzer muss: +- Upload-Rechte haben +- Album-Erstellungsrechte haben (falls verwendet) + +### 3. Server-Limits + +Prüfe `php.ini`: +```ini +upload_max_filesize = 20M +post_max_size = 25M +max_execution_time = 120 +``` + +--- + +## Test-Kommandos + +### Test 1: CSRF-Token holen +```bash +curl -i https://your-lychee.com/ +# Suche nach: XSRF-TOKEN Cookie oder csrf-token Meta-Tag +``` + +### Test 2: Login testen +```bash +curl -X POST https://your-lychee.com/api/Session::login \ + -H "X-CSRF-TOKEN: your-token" \ + -H "X-XSRF-TOKEN: your-token" \ + -H "Accept: application/json" \ + -H "X-Requested-With: XMLHttpRequest" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=yourpassword" +``` + +### Test 3: Session prüfen +```bash +curl -X POST https://your-lychee.com/api/Session::info \ + -H "Cookie: lychee_session=..." \ + -H "Accept: application/json" +``` + +--- + +## Bekannte Probleme + +### Problem: CSRF-Token wird nicht gefunden + +**Lösung:** +- Stelle sicher, dass die Lychee-Hauptseite erreichbar ist +- Prüfe, ob XSRF-TOKEN Cookie gesetzt wird +- Fallback auf HTML-Parsing sollte automatisch erfolgen + +### Problem: Cookies werden nicht gespeichert + +**Lösung:** +- `CookieContainer` wird korrekt initialisiert +- Prüfe `UseCookies = true` in `HttpClientHandler` +- Bei HTTPS: Zertifikat muss gültig sein + +### Problem: Upload zu langsam + +**Lösung:** +- Erhöhe `_httpClient.Timeout` (aktuell: 5 Minuten) +- Prüfe Netzwerkverbindung zur Lychee-Instanz +- Bei vielen Uploads: Batch-Size reduzieren + +--- + +## Support + +Bei weiteren Problemen: + +1. **Logs sammeln:** + - Debug-Konsole aktivieren + - Kompletten Log-Output kopieren + - Besonders `[DEBUG]` und `[ERROR]` Zeilen + +2. **Lychee-Logs prüfen:** + - `storage/logs/laravel.log` auf dem Server + +3. **Browser-DevTools:** + - Manuell im Webinterface anmelden + - Network-Tab beobachten + - Request/Response-Headers vergleichen + +4. **Konfiguration teilen:** + ```json + { + "LycheeSettings": { + "ApiUrl": "...", + "Username": "...", + "Password": "[REDACTED]" + } + } + ``` diff --git a/src/CamBooth/CamBooth.App/Features/LycheeUpload/V2_API_SESSION_DEBUG.md b/src/CamBooth/CamBooth.App/Features/LycheeUpload/V2_API_SESSION_DEBUG.md new file mode 100644 index 0000000..7cb6220 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/LycheeUpload/V2_API_SESSION_DEBUG.md @@ -0,0 +1,204 @@ +# Lychee v2 API Session Management + +## Problem: Session Expired (nach erfolgreichem Login) + +Der Fehler tritt auf, wenn: +1. ✅ Login ist erfolgreich +2. ❌ Aber der nächste Request (Upload) bekommt "Session expired" + +## Root Cause Analyse + +Lychee v2 API kann auf zwei Arten authentifizieren: + +### Option A: Cookie-basiert (Session) +``` +1. POST /api/v2/Auth::login +2. Lychee setzt Session-Cookie (z.B. PHPSESSID) +3. Browser/Client speichert Cookie automatisch +4. Nächster Request sendet Cookie automatisch +``` + +**Problem:** Session läuft ab oder wird nicht richtig gespeichert + +### Option B: Token-basiert (API-Token) +``` +1. POST /api/v2/Auth::login +2. Lychee gibt back: {"api_token": "abc123..."} +3. Client speichert Token +4. Nächster Request: Authorization: Bearer abc123 +``` + +**Besser:** Funktioniert auch wenn Session abläuft + +## Debug Output interpretieren + +### Erfolgreich (Variante A - Cookie): +``` +[DEBUG] Login Response: {"success":true,...} +[DEBUG] Cookies nach Login: 1 +[DEBUG] Cookie: PHPSESSID = abc123def456... +[DEBUG] Cookies für Upload: 1 +[DEBUG] Cookie: PHPSESSID = abc123def456... +[INFO] Upload erfolgreich +``` + +### Erfolgreich (Variante B - Token): +``` +[DEBUG] Login Response: {"success":true,"api_token":"xyz789..."} +[DEBUG] ✅ API-Token erhalten: xyz789... +[DEBUG] Authorization Header vorhanden: true +[INFO] Upload erfolgreich +``` + +### Fehlgeschlagen (Session abgelaufen): +``` +[DEBUG] Cookies nach Login: 0 // ← Kein Cookie! +[DEBUG] Cookies für Upload: 0 // ← Immer noch kein Cookie! +[ERROR] Session expired +``` + +### Fehlgeschlagen (Token nicht in Response): +``` +[DEBUG] Login Response: {...keine api_token...} +[DEBUG] Authorization Header vorhanden: false +[ERROR] Session expired +``` + +## Lösungsansätze + +### Lösung 1: API-Token verwenden (empfohlen) + +Der Code prüft jetzt automatisch auf `api_token` in der Login-Response: + +```csharp +// Wenn vorhanden: +_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {tokenValue}"); +``` + +**Vorteil:** +- ✅ Token verfällt nicht zwischen Requests +- ✅ Kein Cookie-Handling nötig +- ✅ Funktioniert über verschiedene Netzwerke hinweg + +### Lösung 2: Cookie-Session beibehalten + +Wenn Lychee nur Cookies nutzt: + +```csharp +// CookieContainer sollte Cookies speichern +var cookieContainer = new CookieContainer(); +var handler = new HttpClientHandler { CookieContainer = cookieContainer }; +``` + +**Problem:** Diese Konfiguration ist bereits vorhanden! + +Mögliche Ursachen: +- Cookie-Domain stimmt nicht +- Cookie-Path ist zu restriktiv +- Session-Timeout in Lychee ist sehr kurz + +### Lösung 3: Jeder Upload neu authentifizieren + +Falls Session kurz abläuft: + +```csharp +// Vor jedem Upload neu authentifizieren +await AuthenticateAsync(); +await UploadImageAsync(path); +``` + +## Lychee Server-Konfiguration prüfen + +### In `.env`: +```env +SESSION_DRIVER=cookie # Oder file +SESSION_LIFETIME=1440 # 24 Stunden +SESSION_SECURE=false # Nur für HTTP, true für HTTPS +SESSION_HTTP_ONLY=true +``` + +### In `config/session.php`: +```php +'lifetime' => 1440, // Minuten +'same_site' => 'lax', +'secure' => false, // HTTPS in Production +'http_only' => true, +'path' => '/', +'domain' => null, +``` + +## Expected v2 API Responses + +### Login erfolgreich (mit Token): +```json +{ + "success": true, + "api_token": "eyJ0eXAiOiJKV1QiLCJhbGc...", + "user": { + "id": 1, + "username": "admin", + "email": "admin@example.com" + } +} +``` + +### Login erfolgreich (ohne Token): +```json +{ + "success": true, + "user": { + "id": 1, + "username": "admin" + } +} +``` + +### Upload erfolgreich: +```json +{ + "id": "5c123abc456", + "title": "img_xyz.jpg", + "albums": ["2"], + ... +} +``` + +## Debug-Strategie + +1. **Kompiliere und starte die App neu** +2. **Mache einen Upload-Test** +3. **Suche in den Logs nach:** + - `[DEBUG] Login Response:` → Siehst du `api_token`? + - `[DEBUG] Authorization Header vorhanden:` → true oder false? + - `[DEBUG] Cookies nach Login:` → Wie viele? + - `[DEBUG] Cookies für Upload:` → Sind sie noch vorhanden? + +4. **Je nach Ergebnis:** + - **Wenn api_token vorhanden:** ✅ Code wird ihn verwenden + - **Wenn kein api_token, aber Cookies:** Code nutzt Cookies (hoffentlich funktioniert's) + - **Wenn keine Cookies und kein Token:** ❌ Problem! Session wird nicht weitergegeben + +## Weitere Test-Options + +### curl Test mit API-Token: +```bash +# Login und Token extrahieren +TOKEN=$(curl -s -X POST https://cambooth-pics.rblnews.de/api/v2/Auth::login \ + -H 'Content-Type: application/json' \ + -d '{"username":"itob","password":"VfVyqal&Nv8U&P"}' | jq -r '.api_token') + +# Upload mit Token +curl -X POST https://cambooth-pics.rblnews.de/api/v2/Photos::upload \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@photo.jpg" +``` + +## Zusammenfassung + +Die aktualisierte Code-Version: +1. ✅ Prüft auf `api_token` in der Login-Response +2. ✅ Setzt Authorization Header falls Token vorhanden +3. ✅ Fallback auf Cookie-basierte Authentifizierung +4. ✅ Detailliertes Debug-Logging für Troubleshooting + +Kompiliere die App neu und teste. Die Debug-Logs werden Dir zeigen, ob API-Token oder Cookies verwendet werden! diff --git a/src/CamBooth/CamBooth.App/MainWindow.xaml.cs b/src/CamBooth/CamBooth.App/MainWindow.xaml.cs index 91f2574..349c29c 100644 --- a/src/CamBooth/CamBooth.App/MainWindow.xaml.cs +++ b/src/CamBooth/CamBooth.App/MainWindow.xaml.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Diagnostics; using System.Threading.Tasks; using System.Windows; @@ -12,6 +12,7 @@ using CamBooth.App.Core.Logging; using CamBooth.App.Features.Camera; using CamBooth.App.Features.DebugConsole; using CamBooth.App.Features.LiveView; +using CamBooth.App.Features.LycheeUpload; using CamBooth.App.Features.PictureGallery; using Wpf.Ui.Controls; @@ -31,6 +32,8 @@ public partial class MainWindow : Window private readonly CameraService _cameraService; + private readonly LycheeUploadService _lycheeUploadService; + private bool _isDebugConsoleVisible = true; private bool _isPicturePanelVisible = false; @@ -60,12 +63,14 @@ public partial class MainWindow : Window Logger logger, AppSettingsService appSettings, PictureGalleryService pictureGalleryService, - CameraService cameraService) + CameraService cameraService, + LycheeUploadService lycheeUploadService) { this._logger = logger; this._appSettings = appSettings; this._pictureGalleryService = pictureGalleryService; this._cameraService = cameraService; + this._lycheeUploadService = lycheeUploadService; InitializeComponent(); this.SetVisibilityDebugConsole(_appSettings.IsDebugConsoleVisible); this.SetVisibilityPicturePanel(this._isPicturePanelVisible); @@ -85,6 +90,24 @@ public partial class MainWindow : Window this.DebugCloseButton.Visibility = Visibility.Collapsed; this.HideDebugButton.Visibility = this._appSettings.IsDebugConsoleVisible ? Visibility.Visible : Visibility.Collapsed; + // Initialize Lychee upload if auto-upload is enabled + if (appSettings.LycheeAutoUploadEnabled) + { + logger.Info("Lychee Auto-Upload ist aktiviert. Authentifiziere..."); + _ = Task.Run(async () => + { + var authSuccess = await _lycheeUploadService.AuthenticateAsync(); + if (authSuccess) + { + logger.Info("Lychee-Authentifizierung erfolgreich!"); + } + else + { + logger.Warning("Lychee-Authentifizierung fehlgeschlagen. Auto-Upload wird nicht funktionieren."); + } + }); + } + logger.Info($"config file loaded: '{appSettings.ConfigFileName}'"); logger.Info("MainWindow initialized"); } @@ -175,9 +198,18 @@ public partial class MainWindow : Window return; } - this._liveViewPage = new LiveViewPage(this._logger, this._appSettings, this._cameraService); - this.MainFrame.Navigate(this._liveViewPage); - this._isCameraStarted = true; + try + { + this._liveViewPage = new LiveViewPage(this._logger, this._appSettings, this._cameraService); + this.MainFrame.Navigate(this._liveViewPage); + this._isCameraStarted = true; + } + catch (Exception ex) + { + this._logger.Error($"Failed to start live view: {ex.Message}\n{ex.StackTrace}"); + System.Windows.MessageBox.Show($"Failed to initialize camera. Please ensure the camera is properly connected.\n\nError: {ex.Message}", + "Camera Connection Error", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); + } } diff --git a/src/CamBooth/CamBooth.App/ToDos.txt b/src/CamBooth/CamBooth.App/ToDos.txt index d9fafef..7f3eabb 100644 --- a/src/CamBooth/CamBooth.App/ToDos.txt +++ b/src/CamBooth/CamBooth.App/ToDos.txt @@ -7,4 +7,5 @@ - Starbildschirm mit freundlicher Begrüßung, kurzer Erklärung, und viel Spaß wünschen mit der FotoCam - Verschiedene Hinweise anzeigen beim Fotografieren (lächeln, Hasensohren, Zunge raus, Grimasse, usw.) - Bild über QR Code runterladen -- Windows updates deaktivieren \ No newline at end of file +- Windows updates deaktivieren +- logging einbinden (Elastic order ähnliches) \ No newline at end of file diff --git a/src/CamBooth/EDSDKLib/artifacts/out/Debug/EDSDKLib.dll b/src/CamBooth/EDSDKLib/artifacts/out/Debug/EDSDKLib.dll new file mode 100644 index 0000000..ffa9c4b Binary files /dev/null and b/src/CamBooth/EDSDKLib/artifacts/out/Debug/EDSDKLib.dll differ diff --git a/src/CamBooth/EDSDKLib/artifacts/out/Debug/EDSDKLib.pdb b/src/CamBooth/EDSDKLib/artifacts/out/Debug/EDSDKLib.pdb new file mode 100644 index 0000000..f11a122 Binary files /dev/null and b/src/CamBooth/EDSDKLib/artifacts/out/Debug/EDSDKLib.pdb differ