using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using StarfaceOutlookSync.Models; namespace StarfaceOutlookSync.Services { public class StarfaceApiClient : IDisposable { private readonly HttpClient _http; private readonly StarfaceConnection _connection; private readonly string _baseUrl; private string _token; public StarfaceApiClient(StarfaceConnection connection) { _connection = connection; var handler = new HttpClientHandler(); // Self-signed Zertifikate der Starface akzeptieren handler.ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) => true; _http = new HttpClient(handler); _http.DefaultRequestHeaders.Add("X-Version", "2"); _http.Timeout = TimeSpan.FromSeconds(30); var protocol = connection.UseSsl ? "https" : "http"; var portPart = (connection.UseSsl && connection.Port == 443) || (!connection.UseSsl && connection.Port == 80) ? "" : $":{connection.Port}"; _baseUrl = $"{protocol}://{connection.Host}{portPart}/rest"; } private static string Sha512(string input) { using (var sha = SHA512.Create()) { var bytes = Encoding.UTF8.GetBytes(input); var hash = sha.ComputeHash(bytes); var sb = new StringBuilder(128); foreach (var b in hash) sb.Append(b.ToString("x2")); return sb.ToString(); } } public async Task LoginAsync() { try { // Schritt 1: Nonce holen var nonceResp = await _http.GetAsync($"{_baseUrl}/login"); if (!nonceResp.IsSuccessStatusCode) return false; var nonceJson = JObject.Parse(await nonceResp.Content.ReadAsStringAsync()); var loginType = nonceJson["loginType"]?.ToString() ?? "Internal"; var nonce = nonceJson["nonce"]?.ToString() ?? ""; // Schritt 2: Secret berechnen var passwordHash = Sha512(_connection.Password); var combined = _connection.LoginId + nonce + passwordHash; var combinedHash = Sha512(combined); var secret = $"{_connection.LoginId}:{combinedHash}"; // Schritt 3: Login var loginBody = new { loginType, nonce, secret }; var content = new StringContent(JsonConvert.SerializeObject(loginBody), Encoding.UTF8, "application/json"); var loginResp = await _http.PostAsync($"{_baseUrl}/login", content); if (!loginResp.IsSuccessStatusCode) return false; var tokenJson = JObject.Parse(await loginResp.Content.ReadAsStringAsync()); _token = tokenJson["token"]?.ToString(); if (!string.IsNullOrEmpty(_token)) { _http.DefaultRequestHeaders.Remove("authToken"); _http.DefaultRequestHeaders.Add("authToken", _token); } return !string.IsNullOrEmpty(_token); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Starface login failed: {ex.Message}"); return false; } } public async Task LogoutAsync() { if (string.IsNullOrEmpty(_token)) return; try { await _http.DeleteAsync($"{_baseUrl}/login"); } catch { } _token = null; } public async Task GetCurrentUserIdAsync() { try { var resp = await _http.GetAsync($"{_baseUrl}/users/me"); if (!resp.IsSuccessStatusCode) return null; var json = JObject.Parse(await resp.Content.ReadAsStringAsync()); return json["id"]?.ToString(); } catch { return null; } } public async Task> GetAddressBooksAsync() { var books = new List(); // Alle Tags laden - die Starface nutzt Tags als Adressbuch-Zuordnung var allTags = new JArray(); try { var resp = await _http.GetAsync($"{_baseUrl}/contacts/tags"); if (resp.IsSuccessStatusCode) { allTags = JArray.Parse(await resp.Content.ReadAsStringAsync()); OnDebug?.Invoke($"Gefundene Tags: {allTags.Count}"); foreach (var t in allTags) OnDebug?.Invoke($" Tag: {t["name"]} (id: {t["id"]}, alias: {t["alias"]}, owner: {t["owner"]})"); } } catch { } // Zentrales Adressbuch (folder/all) var allTag = allTags.FirstOrDefault(t => t["name"]?.ToString() == "folder/all" || t["alias"]?.ToString()?.Contains("folder.all") == true); books.Add(new StarfaceAddressBook { Type = "central", TagId = allTag?["id"]?.ToString() ?? "", Name = "Zentrales Adressbuch" }); // Persoenliches Adressbuch (folder/private mit owner = userId) var userId = await GetCurrentUserIdAsync(); if (!string.IsNullOrEmpty(userId)) { var privateTag = allTags.FirstOrDefault(t => (t["name"]?.ToString() == "folder/private" || t["alias"]?.ToString()?.Contains("folder.private") == true) && t["owner"]?.ToString() == userId); books.Add(new StarfaceAddressBook { Type = "user", UserId = userId, TagId = privateTag?["id"]?.ToString() ?? "", Name = "Persoenliches Adressbuch" }); } // Alle weiteren Tags als Adressbuecher anbieten foreach (var tag in allTags) { var tagName = tag["name"]?.ToString() ?? ""; // folder/all und folder/private bereits oben erfasst if (tagName == "folder/all" || tagName == "folder/private") continue; books.Add(new StarfaceAddressBook { Type = "tag", TagId = tag["id"]?.ToString() ?? "", Name = tagName }); } return books; } public event Action OnDebug; public async Task> GetContactsAsync(StarfaceAddressBook book) { var contacts = new List(); int page = 0; const int pageSize = 200; bool firstPage = true; while (true) { var query = $"page={page}&pagesize={pageSize}"; if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId)) query += $"&userId={book.UserId}"; if (book.Type == "tag" && !string.IsNullOrEmpty(book.TagId)) query += $"&tags={book.TagId}"; var resp = await _http.GetAsync($"{_baseUrl}/contacts?{query}"); if (!resp.IsSuccessStatusCode) break; var body = await resp.Content.ReadAsStringAsync(); JArray array; // Die API gibt je nach Version ein Array oder ein Objekt mit "items" zurueck var token = JToken.Parse(body); if (token is JArray directArray) { array = directArray; } else if (token is JObject obj) { // Versuche gaengige Felder: items, contacts, data, results array = (obj["items"] ?? obj["contacts"] ?? obj["data"] ?? obj["results"]) as JArray; if (array == null) { // Einzelnes Kontakt-Objekt? Dann in Array wrappen if (obj["id"] != null && obj["blocks"] != null) { array = new JArray { obj }; } else { System.Diagnostics.Debug.WriteLine($"Unerwartete Starface-Antwort: {body.Substring(0, Math.Min(200, body.Length))}"); break; } } } else { break; } if (array.Count == 0) break; OnDebug?.Invoke($"Seite {page}: {array.Count} Kontakte in Liste"); // Die Listen-API gibt nur Summary zurueck. // Jeden Kontakt einzeln abrufen fuer alle Felder. foreach (var item in array) { var id = item["id"]?.ToString(); if (string.IsNullOrEmpty(id)) continue; try { var detailResp = await _http.GetAsync($"{_baseUrl}/contacts/{id}"); if (detailResp.IsSuccessStatusCode) { var detailBody = await detailResp.Content.ReadAsStringAsync(); var detailObj = JObject.Parse(detailBody); if (firstPage) { OnDebug?.Invoke($"Starface Kontakt-Detail (1. Kontakt):\n{detailObj.ToString(Formatting.Indented)}"); firstPage = false; } contacts.Add(MapFromStarface(detailObj)); } } catch { } } if (array.Count < pageSize) break; page++; } return contacts; } public async Task CreateContactAsync(UnifiedContact contact, StarfaceAddressBook book) { var sfContact = MapToStarface(contact); // Tag zuweisen - die Starface verlangt dass jeder Kontakt einem Tag zugeordnet ist if (!string.IsNullOrEmpty(book.TagId)) { sfContact["tags"] = new JArray { new JObject { ["id"] = book.TagId } }; } var query = ""; if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId)) query = $"?userId={book.UserId}"; var body = sfContact.ToString(); OnDebug?.Invoke($"POST /contacts{query} Body:\n{body}"); var content = new StringContent(body, Encoding.UTF8, "application/json"); var resp = await _http.PostAsync($"{_baseUrl}/contacts{query}", content); var respBody = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) { OnDebug?.Invoke($"POST /contacts fehlgeschlagen: {(int)resp.StatusCode} {resp.StatusCode}\n{respBody}"); return null; } var created = JObject.Parse(respBody); return MapFromStarface(created); } public async Task UpdateContactAsync(string contactId, UnifiedContact contact, StarfaceAddressBook book) { var sfContact = MapToStarface(contact); sfContact["id"] = contactId; // Tag beibehalten if (!string.IsNullOrEmpty(book.TagId)) { sfContact["tags"] = new JArray { new JObject { ["id"] = book.TagId } }; } var query = ""; if (book.Type == "user" && !string.IsNullOrEmpty(book.UserId)) query = $"?userId={book.UserId}"; var body = sfContact.ToString(); var content = new StringContent(body, Encoding.UTF8, "application/json"); var resp = await _http.PutAsync($"{_baseUrl}/contacts/{contactId}{query}", content); if (!resp.IsSuccessStatusCode) { var respBody = await resp.Content.ReadAsStringAsync(); OnDebug?.Invoke($"PUT /contacts/{contactId} fehlgeschlagen: {(int)resp.StatusCode}\n{respBody}"); } return resp.IsSuccessStatusCode; } public async Task DeleteContactAsync(string contactId) { var resp = await _http.DeleteAsync($"{_baseUrl}/contacts/{contactId}"); return resp.IsSuccessStatusCode; } private UnifiedContact MapFromStarface(JToken item) { var contact = new UnifiedContact(); contact.StarfaceId = item["id"]?.ToString() ?? ""; // Attribute per "name"-Feld mappen (zuverlaessiger als displayKey, // weil viele Felder USER_DEFINED als displayKey haben) var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); var byDisplayKey = new Dictionary(StringComparer.OrdinalIgnoreCase); var blocks = item["blocks"] as JArray; if (blocks != null) { foreach (var block in blocks) { var blockAttrs = block["attributes"] as JArray; if (blockAttrs == null) continue; foreach (var attr in blockAttrs) { var name = attr["name"]?.ToString() ?? ""; var displayKey = attr["displayKey"]?.ToString() ?? ""; var val = attr["value"]?.ToString() ?? ""; if (!string.IsNullOrEmpty(val)) { if (!string.IsNullOrEmpty(name)) byName[name] = val; // displayKey nur als Fallback (viele sind USER_DEFINED) if (!string.IsNullOrEmpty(displayKey) && displayKey != "USER_DEFINED") byDisplayKey[displayKey] = val; } } } } // Primaer nach name-Feld mappen, Fallback auf displayKey string Get(string name, string displayKey = null) { if (byName.TryGetValue(name, out var v)) return v; if (displayKey != null && byDisplayKey.TryGetValue(displayKey, out v)) return v; return ""; } contact.FirstName = Get("firstname", "NAME"); contact.LastName = Get("familyname", "SURNAME"); contact.Company = Get("company", "COMPANY"); contact.JobTitle = Get("jobtitle", "JOB_TITLE"); contact.Email = Get("e-mail", "EMAIL"); contact.PhoneWork = Get("phone", "PHONE_NUMBER"); contact.PhoneMobile = Get("mobile", "MOBILE_PHONE_NUMBER"); contact.PhoneHome = Get("homephone", "PRIVATE_PHONE_NUMBER"); contact.Fax = Get("fax", "FAX_NUMBER"); contact.Street = Get("street", "STREET"); contact.City = Get("city", "CITY"); contact.PostalCode = Get("postcode", "POSTAL_CODE"); contact.State = Get("state", "STATE"); contact.Country = Get("country", "COUNTRY"); contact.Website = Get("url", "URL"); contact.Notes = Get("comment", "NOTE"); contact.Salutation = Get("salutation", "SALUTATION"); contact.Title = Get("title", "TITLE"); contact.Birthday = Get("birthday", "BIRTHDAY"); return contact; } private JObject MapToStarface(UnifiedContact contact) { JArray MakeAttrs(params (string displayKey, string name, string value)[] fields) { var arr = new JArray(); foreach (var (dk, n, v) in fields) { if (!string.IsNullOrEmpty(v)) arr.Add(new JObject { ["displayKey"] = dk, ["name"] = n, ["value"] = v }); } return arr; } // Block-Struktur wie von der Starface erwartet var contactBlock = MakeAttrs( ("NAME", "firstname", contact.FirstName), ("SURNAME", "familyname", contact.LastName), ("COMPANY", "company", contact.Company) ); var addressBlock = MakeAttrs( ("USER_DEFINED", "street", contact.Street), ("POSTAL_CODE", "postcode", contact.PostalCode), ("USER_DEFINED", "city", contact.City), ("USER_DEFINED", "state", contact.State), ("USER_DEFINED", "country", contact.Country) ); var phoneBlock = MakeAttrs( ("PHONE_NUMBER", "phone", contact.PhoneWork), ("PRIVATE_PHONE_NUMBER", "homephone", contact.PhoneHome), ("MOBILE_PHONE_NUMBER", "mobile", contact.PhoneMobile), ("FAX_NUMBER", "fax", contact.Fax) ); var emailBlock = MakeAttrs( ("EMAIL", "e-mail", contact.Email), ("URL", "url", contact.Website), ("USER_DEFINED", "comment", contact.Notes) ); var blocks = new JArray(); blocks.Add(new JObject { ["name"] = "contact", ["resourceKey"] = "de.vertico.starface.addressbook.block.label_contact", ["attributes"] = contactBlock }); blocks.Add(new JObject { ["name"] = "address", ["resourceKey"] = "de.vertico.starface.addressbook.block.label_address", ["attributes"] = addressBlock }); blocks.Add(new JObject { ["name"] = "telephone", ["resourceKey"] = "de.vertico.starface.addressbook.block.label_telephone", ["attributes"] = phoneBlock }); blocks.Add(new JObject { ["name"] = "email", ["resourceKey"] = "de.vertico.starface.addressbook.block.label_email", ["attributes"] = emailBlock }); return new JObject { ["id"] = contact.StarfaceId ?? "", ["blocks"] = blocks }; } public void Dispose() { _http?.Dispose(); } } internal static class DictionaryExtensions { public static TValue GetValueOrDefault(this Dictionary dict, TKey key, TValue defaultValue) { return dict.TryGetValue(key, out var value) ? value : defaultValue; } } }