using log4net; using log4net.Config; using Microsoft.Extensions.Configuration; using RestSharp; using RestSharp.Authenticators; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; internal class Program { private static readonly ILog Log = LogManager.GetLogger(typeof(Program)); static int Main(string[] args) { Console.OutputEncoding = Encoding.UTF8; Console.InputEncoding = Encoding.UTF8; ConfigureLogging(); AppDomain.CurrentDomain.UnhandledException += (_, e) => Log.Fatal("Unhandled exception", e.ExceptionObject as Exception); try { var baseDir = AppContext.BaseDirectory; var appSettingsPath = Path.Combine(baseDir, "appsettings.json"); var config = LoadConfig(baseDir); var apiBase = (config["Crewbrain:ApiBase"] ?? "").TrimEnd('/'); if (string.IsNullOrWhiteSpace(apiBase)) throw new Exception("Missing config 'Crewbrain:ApiBase' (e.g. https://vt-media.crewbrain.com/api)"); // Paths var angebotPath = config["Paths:Angebot"] ?? ""; var auftragPath = config["Paths:Auftrag"] ?? ""; var lieferscheinPath = config["Paths:Lieferschein"] ?? ""; // Upload settings (3 unterschiedliche TypeIDs / Flags) var settingsAngebot = UploadSettings.FromConfig(config, "Crewbrain:Upload:Angebot"); var settingsAuftrag = UploadSettings.FromConfig(config, "Crewbrain:Upload:Auftrag"); var settingsLieferschein = UploadSettings.FromConfig(config, "Crewbrain:Upload:Lieferschein"); Log.Info("=== EPI2CrewbrainFile.Console started ==="); Log.Info($"Config path: {appSettingsPath}"); Log.Info($"Crewbrain ApiBase: {apiBase}"); Log.Info($"AngebotPath: {angebotPath} | TypeId={settingsAngebot.TypeId} | OverwriteSingleDocument={settingsAngebot.OverwriteSingleDocument}"); Log.Info($"AuftragPath: {auftragPath} | TypeId={settingsAuftrag.TypeId} | OverwriteSingleDocument={settingsAuftrag.OverwriteSingleDocument}"); Log.Info($"LieferscheinPath: {lieferscheinPath} | TypeId={settingsLieferschein.TypeId} | OverwriteSingleDocument={settingsLieferschein.OverwriteSingleDocument}"); // Access token (prompt once, store) var accessToken = TokenAuthHelper.EnsureAccessToken( config: config, appSettingsPath: appSettingsPath, log: Log, apiBase: apiBase ); Log.Info($"TokenAuth: AccessToken loaded (len={accessToken.Length})"); // Uploader service var service = new UploaderService(apiBase, accessToken, appSettingsPath, Log); // Process ProcessFolder(folder: angebotPath, uploader: service, tag: "Angebot", settings: settingsAngebot); ProcessFolder(folder: auftragPath, uploader: service, tag: "Auftrag", settings: settingsAuftrag); ProcessFolder(folder: lieferscheinPath, uploader: service, tag: "Lieferschein", settings: settingsLieferschein); Log.Info("=== EPI2CrewbrainFile.Console finished successfully ==="); return 0; } catch (Exception ex) { Log.Fatal("Fatal error", ex); return 1; } finally { LogManager.Shutdown(); } } private static IConfigurationRoot LoadConfig(string baseDir) => new ConfigurationBuilder() .SetBasePath(baseDir) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) .Build(); private static void ConfigureLogging() { Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "logs")); var configFile = Path.Combine(AppContext.BaseDirectory, "log4net.config"); XmlConfigurator.Configure(LogManager.GetRepository(Assembly.GetEntryAssembly()!), new FileInfo(configFile)); } private static void ProcessFolder(string folder, UploaderService uploader, string tag, UploadSettings settings) { if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) { Log.Warn($"[{tag}] Folder missing: {folder}"); return; } var pdfs = new DirectoryInfo(folder).GetFiles("*.pdf"); Log.Info($"[{tag}] Found {pdfs.Length} PDF(s) in {folder}"); foreach (var file in pdfs) { try { var eventIdManual = ExtractEventIdManual(file.Name); if (string.IsNullOrWhiteSpace(eventIdManual)) { Log.Warn($"[{tag}] Could not extract EventIDManual from filename: {file.Name} (skip)"); continue; } Log.Info($"[{tag}] File '{file.Name}' -> EventIDManual={eventIdManual}"); var eventId = uploader.ResolveCrewbrainEventIdByManualId(eventIdManual); if (eventId == null) { Log.Warn($"[{tag}] No CrewBrain event found for EventIDManual={eventIdManual}. File not moved."); continue; } Log.Info($"[{tag}] Resolved EventIDManual={eventIdManual} -> CrewBrain ID={eventId}"); var filenameWithoutExt = Path.GetFileNameWithoutExtension(file.Name); var ok = uploader.UploadDocumentToEvent( eventId: eventId.Value, typeId: settings.TypeId, overwriteSingleDocument: settings.OverwriteSingleDocument, fullPath: file.FullName, filename: filenameWithoutExt, filetype: file.Extension.TrimStart('.') ); if (ok) { MoveToUploaded(folder, file.Name, tag); } else { Log.Warn($"[{tag}] Upload failed -> file NOT moved. ({file.Name})"); } } catch (Exception ex) { Log.Error($"[{tag}] Error processing '{file.FullName}'", ex); } } } /// /// Robust: findet 3- bis 5-stellige IDs im Format NNN-NN / NNNN-NN / NNNNN-NN. /// Wenn mehrere Treffer existieren, nimmt er den längsten (also bevorzugt 5-stellig vor 4-stellig vor 3-stellig). /// private static string ExtractEventIdManual(string fileName) { // Match: 3..5 digits - 2 digits, nicht Teil einer größeren Zahl var matches = Regex.Matches(fileName, @"(? m.Groups[1].Value) .OrderByDescending(s => s.Length) // bevorzugt 12345-01 .First(); } private static void MoveToUploaded(string baseFolder, string fileName, string tag) { var uploadedDir = Path.Combine(baseFolder, "Uploaded"); Directory.CreateDirectory(uploadedDir); var source = Path.Combine(baseFolder, fileName); if (!File.Exists(source)) { Log.Warn($"[{tag}] Move: source missing: {source}"); return; } var nameNoExt = Path.GetFileNameWithoutExtension(fileName); var ext = Path.GetExtension(fileName); // inkl ".pdf" if (string.IsNullOrWhiteSpace(ext)) ext = ""; var dest = Path.Combine(uploadedDir, fileName); if (File.Exists(dest)) { var stamp = DateTime.Now.ToString("yyyyMMddHHmmssffff"); var newName = $"{nameNoExt}_{stamp}{ext}"; dest = Path.Combine(uploadedDir, newName); } File.Move(source, dest); Log.Info($"[{tag}] Moved to '{dest}'"); } private sealed record UploadSettings(int TypeId, bool OverwriteSingleDocument) { public static UploadSettings FromConfig(IConfiguration cfg, string prefix) { int typeId = 0; bool overwrite = false; int.TryParse(cfg[$"{prefix}:TypeId"], out typeId); bool.TryParse(cfg[$"{prefix}:OverwriteSingleDocument"], out overwrite); return new UploadSettings(typeId, overwrite); } } } internal static class TokenAuthHelper { public static string EnsureAccessToken(IConfiguration config, string appSettingsPath, ILog log, string apiBase) { apiBase = (apiBase ?? "").TrimEnd('/'); var token = (config["Crewbrain:TokenAuth:AccessToken"] ?? "").Trim(); if (!string.IsNullOrWhiteSpace(token)) { log.Info("TokenAuth: Using stored AccessToken from appsettings.json"); return token; } log.Warn("TokenAuth: No AccessToken stored. Prompting for username/password to request one-time token..."); Console.Write("CrewBrain Username: "); var user = (Console.ReadLine() ?? "").Trim(); var pass = ReadPassword("CrewBrain Password: "); if (string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(pass)) throw new Exception("TokenAuth: Missing username/password; cannot request access token."); var fetched = FetchAccessToken(apiBase, user, pass, log); SaveAccessToken(appSettingsPath, fetched, log); log.Info("TokenAuth: AccessToken saved to appsettings.json"); return fetched; } public static void ClearStoredToken(string appSettingsPath, ILog log) { try { var json = File.Exists(appSettingsPath) ? File.ReadAllText(appSettingsPath) : ""; var root = (JsonNode.Parse(json) as JsonObject) ?? new JsonObject(); var crew = root["Crewbrain"] as JsonObject; var tokenAuth = crew?["TokenAuth"] as JsonObject; if (tokenAuth == null) return; tokenAuth["AccessToken"] = ""; tokenAuth["ClearedAtUtc"] = DateTime.UtcNow.ToString("o"); File.WriteAllText(appSettingsPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); log.Warn("TokenAuth: Stored token cleared in appsettings.json (will re-prompt next time)."); } catch (Exception ex) { log.Warn("TokenAuth: Failed to clear token from appsettings.json", ex); } } private static string FetchAccessToken(string apiBase, string user, string pass, ILog log) { var options = new RestClientOptions(apiBase.TrimEnd('/') + "/") { Authenticator = new HttpBasicAuthenticator(user, pass), FollowRedirects = true }; var client = new RestClient(options); var req = new RestRequest("accesstoken", Method.Get); req.AddHeader("Accept", "application/json"); var resp = client.Execute(req); log.Info($"GET /accesstoken -> {(int)resp.StatusCode} {resp.StatusCode}"); if (!resp.IsSuccessful) throw new Exception($"AccessToken request failed: {(int)resp.StatusCode} {resp.StatusCode} Body={resp.Content}"); var body = resp.Content ?? ""; var node = JsonNode.Parse(body) as JsonObject ?? throw new Exception($"AccessToken response not a JSON object: {body}"); var token = (node["Accesstoken"]?.GetValue() ?? "").Trim(); if (string.IsNullOrWhiteSpace(token)) throw new Exception($"TokenAuth: 'Accesstoken' missing in response: {body}"); log.Info($"TokenAuth: token received (len={token.Length})"); return token; } private static void SaveAccessToken(string appSettingsPath, string accessToken, ILog log) { JsonObject root; try { var json = File.Exists(appSettingsPath) ? File.ReadAllText(appSettingsPath) : ""; root = (JsonNode.Parse(json) as JsonObject) ?? new JsonObject(); } catch (Exception ex) { if (File.Exists(appSettingsPath)) { var backupPath = appSettingsPath + ".broken_" + DateTime.Now.ToString("yyyyMMddHHmmss"); File.Copy(appSettingsPath, backupPath, overwrite: true); log.Warn($"TokenAuth: appsettings.json invalid JSON. Backed up to: {backupPath}", ex); } root = new JsonObject(); } var crew = root["Crewbrain"] as JsonObject ?? new JsonObject(); root["Crewbrain"] = crew; var tokenAuth = crew["TokenAuth"] as JsonObject ?? new JsonObject(); crew["TokenAuth"] = tokenAuth; tokenAuth["AccessToken"] = accessToken; tokenAuth["SavedAtUtc"] = DateTime.UtcNow.ToString("o"); File.WriteAllText(appSettingsPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); } private static string ReadPassword(string prompt) { Console.Write(prompt); var sb = new StringBuilder(); while (true) { var key = Console.ReadKey(intercept: true); if (key.Key == ConsoleKey.Enter) { Console.WriteLine(); break; } if (key.Key == ConsoleKey.Backspace) { if (sb.Length > 0) { sb.Length--; Console.Write("\b \b"); } continue; } if (!char.IsControl(key.KeyChar)) { sb.Append(key.KeyChar); Console.Write("*"); } } return sb.ToString(); } } internal class UploaderService { private static readonly ILog Log = LogManager.GetLogger(typeof(UploaderService)); private readonly string _baseUrl; // https://.../api/ private string _accessToken; // X-API-KEY private readonly string _appSettingsPath; // for clearing token on 401/403 private readonly ILog _rootLog; public UploaderService(string apiBase, string accessToken, string appSettingsPath, ILog rootLog) { _baseUrl = (apiBase ?? "").TrimEnd('/') + "/"; _accessToken = (accessToken ?? "").Trim(); _appSettingsPath = appSettingsPath; _rootLog = rootLog; } public int? ResolveCrewbrainEventIdByManualId(string eventIdManual) => ResolveCrewbrainEventIdByManualIdInternal(eventIdManual, allowTokenRetry: true); private int? ResolveCrewbrainEventIdByManualIdInternal(string eventIdManual, bool allowTokenRetry) { try { var client = new RestClient(new RestClientOptions(_baseUrl) { FollowRedirects = true }); var req = new RestRequest("v2/events", Method.Get); req.AddHeader("Accept", "application/json"); req.AddHeader("X-API-KEY", _accessToken); req.AddQueryParameter("EventIDManual", eventIdManual); DebugLogRawRequest(client, req, bodyPreview: null); var resp = client.Execute(req); Log.Info($"GET v2/events?EventIDManual={eventIdManual} -> {(int)resp.StatusCode} {resp.StatusCode}"); if ((int)resp.StatusCode == 401 || (int)resp.StatusCode == 403) { Log.Error("Resolve: unauthorized/forbidden. Token might be invalid/expired."); if (allowTokenRetry && TryReAuthToken()) return ResolveCrewbrainEventIdByManualIdInternal(eventIdManual, allowTokenRetry: false); return null; } if (!resp.IsSuccessful) { var b = resp.Content ?? ""; if (b.Length > 1200) b = b.Substring(0, 1200) + "...(truncated)"; Log.Warn($"Resolve event failed: {(int)resp.StatusCode} {resp.StatusCode} Body={b}"); return null; } var body = resp.Content ?? ""; var root = JsonNode.Parse(body) as JsonObject; var data = root?["data"] as JsonArray; if (data == null || data.Count == 0) { Log.Warn($"Resolve event: no items for EventIDManual={eventIdManual}"); return null; } if (data.Count > 1) Log.Warn($"Resolve event: multiple items ({data.Count}) for EventIDManual={eventIdManual}. Using first."); var first = data[0] as JsonObject; var idNode = first?["ID"]; if (idNode == null) { Log.Warn($"Resolve event: 'ID' missing in first item for EventIDManual={eventIdManual}"); return null; } return idNode.GetValue(); } catch (Exception ex) { Log.Error($"ResolveCrewbrainEventIdByManualId failed (EventIDManual={eventIdManual})", ex); return null; } } public bool UploadDocumentToEvent(int eventId, int typeId, bool overwriteSingleDocument, string fullPath, string filename, string filetype) => UploadDocumentToEventInternal(eventId, typeId, overwriteSingleDocument, fullPath, filename, filetype, allowTokenRetry: true); private bool UploadDocumentToEventInternal(int eventId, int typeId, bool overwriteSingleDocument, string fullPath, string filename, string filetype, bool allowTokenRetry) { try { filename = (filename ?? "").Trim(); filetype = (filetype ?? "").Trim().TrimStart('.'); if (string.IsNullOrWhiteSpace(filename)) filename = Path.GetFileNameWithoutExtension(fullPath) ?? ""; if (string.IsNullOrWhiteSpace(filename)) throw new Exception("UploadDocumentToEvent: filename is empty."); if (string.IsNullOrWhiteSpace(filetype)) filetype = "pdf"; var bytes = File.ReadAllBytes(fullPath); var base64 = Convert.ToBase64String(bytes); // API fordert "Filename" mandatory → wir geben hier den kompletten Dateinamen inkl. Extension mit, // aber lassen dein "filename" (ohne Ext) drin lesbar. var filenameWithExt = filename.EndsWith("." + filetype, StringComparison.OrdinalIgnoreCase) ? filename : $"{filename}.{filetype}"; var payload = new { Base64String = base64, Filename = filenameWithExt, Filetype = filetype }; var client = new RestClient(new RestClientOptions(_baseUrl) { FollowRedirects = true }); var req = new RestRequest($"v2/events/{eventId}/additionalDatas/{typeId}/addDocument", Method.Put); req.AddHeader("Accept", "application/json"); req.AddHeader("Content-Type", "application/json"); req.AddHeader("X-API-KEY", _accessToken); if (overwriteSingleDocument) req.AddQueryParameter("overwriteSingleDocument", "true"); var jsonBody = JsonSerializer.Serialize(payload); req.AddStringBody(jsonBody, DataFormat.Json); // DEBUG Raw Request (ohne Base64) var bodyPreview = JsonSerializer.Serialize(new { Base64String = $"", Filename = filenameWithExt, Filetype = filetype }); DebugLogRawRequest(client, req, bodyPreview); var resp = client.Execute(req); Log.Info($"PUT v2/events/{eventId}/additionalDatas/{typeId}/addDocument -> {(int)resp.StatusCode} {resp.StatusCode}"); if ((int)resp.StatusCode == 401 || (int)resp.StatusCode == 403) { Log.Error("Upload: unauthorized/forbidden. Token might be invalid/expired."); // NOTE: Bei euch kommt 403 auch für Validierungsfehler (z.B. Filename fehlt) // Darum: ReAuth nur dann, wenn Response "wirklich" nach Auth aussieht. // Wir machen es pragmatisch: wenn allowTokenRetry -> einmal neu Token holen und retry, // ABER nur wenn message NICHT nach Feldvalidierung klingt. var content = resp.Content ?? ""; if (allowTokenRetry && LooksLikeAuthFailure(content) && TryReAuthToken()) return UploadDocumentToEventInternal(eventId, typeId, overwriteSingleDocument, fullPath, filename, filetype, allowTokenRetry: false); } if (!resp.IsSuccessful) { var body = resp.Content ?? ""; if (body.Length > 2000) body = body.Substring(0, 2000) + "...(truncated)"; Log.Warn($"Upload failed: {(int)resp.StatusCode} {resp.StatusCode} Body={body}"); return false; } return true; } catch (Exception ex) { Log.Error($"UploadDocumentToEvent failed (EventID={eventId}, File={fullPath})", ex); return false; } } private bool TryReAuthToken() { try { // Token leeren, dann per EnsureAccessToken neu holen (Prompt) TokenAuthHelper.ClearStoredToken(_appSettingsPath, _rootLog); // Config neu laden, damit EnsureAccessToken die aktuelle Datei sieht var baseDir = AppContext.BaseDirectory; var cfg = new ConfigurationBuilder() .SetBasePath(baseDir) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) .Build(); var apiBase = (cfg["Crewbrain:ApiBase"] ?? "").TrimEnd('/'); var newToken = TokenAuthHelper.EnsureAccessToken(cfg, _appSettingsPath, _rootLog, apiBase); if (string.IsNullOrWhiteSpace(newToken)) return false; _accessToken = newToken.Trim(); _rootLog.Warn("TokenAuth: Re-auth succeeded, token replaced in memory."); return true; } catch (Exception ex) { _rootLog.Warn("TokenAuth: Re-auth failed.", ex); return false; } } private static bool LooksLikeAuthFailure(string body) { if (string.IsNullOrWhiteSpace(body)) return true; // Wenn es klar nach Validierungsfehler aussieht (Missing mandatory field etc.), NICHT reauthen var b = body.ToLowerInvariant(); if (b.Contains("missing mandatory field")) return false; if (b.Contains("\"field\"")) return false; // Auth-typische Signale if (b.Contains("unauthorized")) return true; if (b.Contains("forbidden")) return true; if (b.Contains("invalid token")) return true; if (b.Contains("not authorized")) return true; // default: lieber einmal reauthen als endlos scheitern return true; } private static void DebugLogRawRequest(RestClient client, RestRequest req, string? bodyPreview) { if (!Log.IsDebugEnabled) return; try { var baseUrl = client.Options.BaseUrl?.ToString() ?? "(null)"; var resource = req.Resource ?? ""; var method = req.Method.ToString(); var fullUrl = CombineUrl(baseUrl, resource); var headers = new List(); var query = new List(); foreach (var p in req.Parameters) { if (p.Type == ParameterType.HttpHeader) { var val = p.Value?.ToString() ?? ""; if (p.Name != null && p.Name.Equals("X-API-KEY", StringComparison.OrdinalIgnoreCase)) { val = Mask(val); } headers.Add($"{p.Name}: {val}"); } else if (p.Type == ParameterType.QueryString) { query.Add($"{p.Name}={p.Value}"); } } Log.Debug("----- RAW REQUEST -----"); Log.Debug($"{method} {fullUrl}"); if (query.Count > 0) Log.Debug("Query: " + string.Join("&", query)); if (headers.Count > 0) { Log.Debug("Headers:"); foreach (var h in headers) Log.Debug(" " + h); } if (!string.IsNullOrWhiteSpace(bodyPreview)) { Log.Debug("Body (preview):"); Log.Debug(bodyPreview); } Log.Debug("-----------------------"); } catch (Exception ex) { Log.Debug("Failed to build raw request debug log.", ex); } } private static string CombineUrl(string baseUrl, string resource) { baseUrl = (baseUrl ?? "").TrimEnd('/'); resource = (resource ?? "").TrimStart('/'); return baseUrl + "/" + resource; } private static string Mask(string s) { s = s ?? ""; if (s.Length <= 6) return new string('*', s.Length); return s.Substring(0, 3) + "***" + s.Substring(s.Length - 3) + $" (len={s.Length})"; } }