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);
/// Best-effort nodeinfo software name (mastodon/peertube/...) for a
/// fediverse domain, or null when it can't be resolved. The load-time heal
/// uses this to fix a bar entry whose stored software is missing.
Task ResolveFediverseSoftwareAsync(string domain, 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;
private readonly Action? _log;
public HttpSocialValidator(HttpClient? client = null, Action? log = null)
{
_client = client ?? new HttpClient();
_client.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (compatible; ytLlive social validator)");
_log = log;
}
public Task ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
=> TryFetchFediverseSoftwareAsync(domain, ct);
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.
/// If neither answers, probe well-known subdomains (mastodon.example.com)
/// so a landing-page domain still resolves its real instance. The whole
/// resolution is bounded by a ~15s budget. 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
{
using var budget = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(ct);
budget.CancelAfter(TimeSpan.FromSeconds(15));
var software = await FetchSoftwareNameAsync(domain, budget.Token);
if (software != null) return software;
var resolved = await ResolveInstanceHostAsync(domain, budget.Token);
if (resolved != null
&& !string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase))
{
software = await FetchSoftwareNameAsync(resolved, budget.Token);
if (software != null) return software;
}
foreach (var sub in FediverseSubdomainCandidates)
{
var host = $"{sub}.{domain}";
software = await FetchSoftwareNameAsync(host, budget.Token);
if (software != null)
{
_log?.Invoke($"SocialValidator: fediverse software '{software}' for '{domain}' resolved via {host}");
return software;
}
}
_log?.Invoke($"SocialValidator: no nodeinfo found for '{domain}'");
return null;
}
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
{
return null; // caller checks ct.IsCancellationRequested and reports Canceled
}
catch (System.OperationCanceledException)
{
_log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' timed out");
return null;
}
catch (System.Exception ex)
{
_log?.Invoke($"SocialValidator: nodeinfo resolution for '{domain}' failed: {ex.Message}");
return null;
}
}
/// Common instance subdomains, probed in order when the identity
/// domain itself doesn't serve nodeinfo (a landing page or SSO gate in front
/// of a self-hosted instance).
private static readonly string[] FediverseSubdomainCandidates =
{
"mastodon", "social", "fediverse", "tube", "peertube",
"pixelfed", "lemmy", "pleroma", "misskey", "gotosocial", "fedi", "instance",
};
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;
}
}