540 lines
18 KiB
C#
540 lines
18 KiB
C#
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();
|
||
}
|
||
} |