199 lines
8.2 KiB
C#
199 lines
8.2 KiB
C#
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;
|
|
|
|
/// <summary>Fediverse instance software (nodeinfo `software.name`), when the handle is federated.</summary>
|
|
public string? FediverseSoftware { get; init; }
|
|
|
|
/// <summary>True when the caller canceled the lookup before it finished.</summary>
|
|
public bool Canceled { get; init; }
|
|
}
|
|
|
|
public interface ISocialValidator
|
|
{
|
|
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<SocialLookupResult> 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<SocialLookupResult> 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 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<string?> 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<string?> 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;
|
|
}
|
|
|
|
/// <summary>Follows the bare-domain root redirect (HttpClient follows 3xx automatically)
|
|
/// and returns the final host — the "default app" behind an identity domain.</summary>
|
|
private async Task<string?> ResolveInstanceHostAsync(string domain, CancellationToken ct)
|
|
{
|
|
using var resp = await _client.GetAsync($"https://{domain}/", ct);
|
|
return resp.RequestMessage?.RequestUri?.Host;
|
|
}
|
|
|
|
private async Task<string?> 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;
|
|
}
|
|
}
|