cambooth/src/CamBooth/CamBooth.App/Features/LycheeUpload/LycheeUploadService.cs
2026-02-28 23:15:59 +01:00

540 lines
18 KiB
C#
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Service für den Upload von Bildern zu Lychee mit Authentifizierung
/// </summary>
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");
}
/// <summary>
/// Gibt an, ob der Service authentifiziert ist
/// </summary>
public bool IsAuthenticated => _isAuthenticated;
/// <summary>
/// Authentifiziert sich bei Lychee
/// </summary>
/// <returns>True wenn erfolgreich, sonst False</returns>
public async Task<bool> 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;
}
}
/// <summary>
/// Lädt ein Bild zu Lychee hoch
/// </summary>
/// <param name="imagePath">Pfad zum Bild</param>
/// <param name="albumId">Optional: Album-ID in Lychee</param>
/// <returns>True wenn erfolgreich, sonst False</returns>
public async Task<bool> 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;
}
}
/// <summary>
/// Lädt mehrere Bilder zu Lychee hoch
/// </summary>
/// <param name="imagePaths">Liste von Bildpfaden</param>
/// <param name="albumId">Optional: Album-ID in Lychee</param>
/// <returns>Anzahl erfolgreich hochgeladener Bilder</returns>
public async Task<int> UploadImagesAsync(IEnumerable<string> 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;
}
/// <summary>
/// Erstellt ein neues Album in Lychee
/// </summary>
/// <param name="albumTitle">Titel des Albums</param>
/// <returns>Album-ID wenn erfolgreich, sonst null</returns>
public async Task<string?> 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;
}
}
/// <summary>
/// Meldet sich von Lychee ab
/// </summary>
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();
}
}