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