using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using ytLive.Models; namespace ytLive.Services; public sealed class SocialLookupResult { public bool Success { get; init; } public string ProfileUrl { get; init; } = string.Empty; public string Handle { get; init; } = string.Empty; public string Error { get; init; } = string.Empty; /// Fediverse instance software (nodeinfo `software.name`), when the handle is federated. public string? FediverseSoftware { get; init; } /// True when the caller canceled the lookup before it finished. public bool Canceled { get; init; } } public interface ISocialValidator { Task LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct); } /// /// Validates a social handle/URL by constructing the canonical profile URL and /// issuing an HTTP GET — the "act of validation" the creator trusts before the /// icon lands on their bar. Best-effort: some platforms return challenges/blocks /// to HEAD requests, so a 200 OR a 301/302 redirect counts as "exists"; 404 or /// connection failure = rejected. Never blind trust. /// public sealed class HttpSocialValidator : ISocialValidator { private readonly HttpClient _client; public HttpSocialValidator(HttpClient? client = null) { _client = client ?? new HttpClient(); _client.DefaultRequestHeaders.UserAgent.ParseAdd( "Mozilla/5.0 (compatible; ytLlive social validator)"); } public async Task LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct) { var input = handleOrUrl.Trim(); if (string.IsNullOrWhiteSpace(input)) return new SocialLookupResult { Error = "Enter a handle or URL." }; // Fediverse handle (@user@domain): the domain is part of the identity, // so the canonical URL can't be rebuilt from a bare handle. After the // profile validates, ask the instance which software it runs (nodeinfo) // so the caller can show the right service logo. if (input.StartsWith('@') && input.IndexOf('@', 1) > 0) { var at = input.IndexOf('@', 1); var user = input[1..at]; var domain = input[(at + 1)..]; if (user.Length > 0 && domain.Length > 0) { var url = $"https://{domain}/@{user}"; var result = await CheckAsync(service, input, url, ct); if (!result.Success) return result; var software = await TryFetchFediverseSoftwareAsync(domain, ct); if (ct.IsCancellationRequested) return new SocialLookupResult { Canceled = true }; return new SocialLookupResult { Success = true, ProfileUrl = result.ProfileUrl, Handle = result.Handle, FediverseSoftware = software, }; } } var handle = input; if (input.StartsWith("http://", System.StringComparison.OrdinalIgnoreCase) || input.StartsWith("https://", System.StringComparison.OrdinalIgnoreCase)) { var uri = new System.Uri(input); handle = uri.AbsolutePath.Trim('/'); } var canonical = SocialServiceIcons.CanonicalUrlFor(service, handle); return await CheckAsync(service, handle, canonical, ct); } private async Task CheckAsync(SocialService service, string handle, string url, CancellationToken ct) { try { using var resp = await _client.GetAsync(url, ct); if (resp.IsSuccessStatusCode || (int)resp.StatusCode is 301 or 302 or 303 or 307 or 308) return new SocialLookupResult { Success = true, ProfileUrl = url, Handle = handle, }; return new SocialLookupResult { Error = $"{service} returned {resp.StatusCode} for '{handle}'. The handle may be wrong or the page doesn't exist.", }; } catch (System.OperationCanceledException) when (ct.IsCancellationRequested) { return new SocialLookupResult { Canceled = true }; } catch (System.Net.Http.HttpRequestException) { return new SocialLookupResult { Error = $"Couldn't reach {service} to validate '{handle}'. Check your connection and try again.", }; } catch (System.Exception ex) { return new SocialLookupResult { Error = ex.Message }; } } /// /// Best-effort nodeinfo lookup: GET /.well-known/nodeinfo, follow the first /// nodeinfo link, read `software.name`. If the identity domain is itself a /// redirect (e.g. a YunoHost domain whose default app lives on a subdomain), /// the bare root 302s to the real instance — follow it and ask that host. /// Failures return null — validation still succeeds, the icon just falls /// back to the generic fediverse glyph. /// private async Task TryFetchFediverseSoftwareAsync(string domain, CancellationToken ct) { try { var software = await FetchSoftwareNameAsync(domain, ct); if (software != null) return software; var resolved = await ResolveInstanceHostAsync(domain, ct); if (resolved == null || string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase)) return null; return await FetchSoftwareNameAsync(resolved, ct); } catch (System.OperationCanceledException) when (ct.IsCancellationRequested) { return null; // caller checks ct.IsCancellationRequested and reports Canceled } catch { return null; } } private async Task FetchSoftwareNameAsync(string domain, CancellationToken ct) { var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct); if (nodeInfoUrl == null) return null; using var resp = await _client.GetAsync(nodeInfoUrl, ct); if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(json); if (doc.RootElement.TryGetProperty("software", out var software) && software.TryGetProperty("name", out var name)) return name.GetString(); return null; } /// Follows the bare-domain root redirect (HttpClient follows 3xx automatically) /// and returns the final host — the "default app" behind an identity domain. private async Task ResolveInstanceHostAsync(string domain, CancellationToken ct) { using var resp = await _client.GetAsync($"https://{domain}/", ct); return resp.RequestMessage?.RequestUri?.Host; } private async Task FetchNodeInfoUrlAsync(string domain, CancellationToken ct) { using var resp = await _client.GetAsync($"https://{domain}/.well-known/nodeinfo", ct); if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("links", out var links)) return null; foreach (var link in links.EnumerateArray()) { if (!link.TryGetProperty("rel", out var rel)) continue; var relStr = rel.GetString() ?? string.Empty; if (!relStr.Contains("nodeinfo", System.StringComparison.OrdinalIgnoreCase)) continue; if (!link.TryGetProperty("href", out var href) || href.GetString() is not { } hrefStr) continue; if (System.Uri.TryCreate(hrefStr, System.UriKind.RelativeOrAbsolute, out var parsed)) return parsed.IsAbsoluteUri ? parsed.AbsoluteUri : new System.Uri(new System.Uri($"https://{domain}"), parsed).AbsoluteUri; } return null; } }