Files
NostrServices/NostrServices/Controllers/OpenGraphController.cs

271 lines
9.7 KiB
C#

using AngleSharp;
using AngleSharp.Dom;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Nostr.Client.Identifiers;
using Nostr.Client.Messages;
using Nostr.Client.Utils;
using NostrServices.Services;
using ProtoBuf;
namespace NostrServices.Controllers;
/// <summary>
/// Add OpenGraph tags to html documents
/// </summary>
[Route("/api/v1/opengraph")]
public class OpenGraphController : Controller
{
private readonly ILogger<OpenGraphController> _logger;
private readonly RedisStore _redisStore;
private readonly HttpClient _httpClient;
public OpenGraphController(ILogger<OpenGraphController> logger, RedisStore redisStore, HttpClient httpClient)
{
_logger = logger;
_redisStore = redisStore;
_httpClient = httpClient;
_httpClient.Timeout = TimeSpan.FromSeconds(2);
}
/// <summary>
/// Inject opengraph tags into provided html
/// </summary>
/// <param name="id">Nostr identifier npub/note/nevent/naddr/nprofile</param>
/// <param name="canonical">Url format for canonical tag <code>https://example.com/%s</code></param>
/// <returns></returns>
[HttpPost("{id}")]
[Consumes("text/html")]
[Produces("text/html")]
public async Task<IActionResult> TagPage([FromRoute] string id, [FromQuery] string? canonical)
{
var cts = HttpContext.RequestAborted;
using var sr = new StreamReader(Request.Body);
var html = await sr.ReadToEndAsync(cts);
void AddCanonical(List<HeadElement> tags, NostrIdentifier idx)
{
if (!string.IsNullOrEmpty(canonical) && canonical.Contains("%s"))
{
var uc = new Uri(canonical.Replace("%s", idx.ToBech32()));
tags.Add(new HeadElement("link", [
new("rel", "canonical"),
new("href", uc.ToString())
]));
}
}
if ((NostrIdentifierParser.TryParse(id, out var nid) && nid != default) ||
(nid = NostrBareIdentifier.Parse(id)) != default ||
(nid = await TryParseNip05(id, cts)) != default)
{
try
{
if (nid.Hrp is "nevent" or "note" or "naddr")
{
var ev = await _redisStore.GetEvent(nid);
if (ev != default)
{
var tags = MetaTagsToElements(await GetEventTags(ev));
AddCanonical(tags, ev.ToIdentifier());
var doc = await InjectTags(html, tags);
return Content(doc?.ToHtml() ?? html, "text/html");
}
}
else if (nid.Hrp is "nprofile" or "npub")
{
var profile = await _redisStore.GetProfile(nid.Special);
var meta = await GetProfileMeta(profile);
var tags = MetaTagsToElements([
new("og:type", "profile"),
new("og:title", meta?.Title ?? ""),
new("og:description", meta?.Description ?? ""),
new("og:image", meta?.Image ?? $"https://robohash.v0l.io/{nid.Special}.png"),
new("og:profile:username", meta?.Profile?.Name ?? "")
]);
AddCanonical(tags, profile?.ToIdentifier() ?? new NostrProfileIdentifier(nid.Special, null));
var doc = await InjectTags(html, tags);
return Content(doc?.ToHtml() ?? html, "text/html");
}
}
catch (Exception ex) when (ex is not TaskCanceledException)
{
_logger.LogWarning("Failed to inject event tags: {Message}", ex.ToString());
}
}
return Content(html, "text/html");
}
private async Task<List<KeyValuePair<string, string>>> GetEventTags(CompactEvent ev)
{
var ret = new List<KeyValuePair<string, string>>();
var profile = await _redisStore.GetProfile(ev.PubKey.ToHex());
var name = profile?.Name ?? "Nostrich";
switch (ev.Kind)
{
case (long)NostrKind.LiveEvent:
{
var host = ev.Tags.FirstOrDefault(a => a is {Key: "p", Values: [_, _, "host"]})?.Values[1] ?? ev.PubKey.ToHex();
var hostProfile = await _redisStore.GetProfile(host);
var hostName = hostProfile?.Name ?? profile?.Name ?? "Nostrich";
var stream = ev.GetFirstTagValue("streaming") ?? ev.GetFirstTagValue("recording") ?? "";
ret.AddRange(new KeyValuePair<string, string>[]
{
new("og:type", "video.other"),
new("og:title", $"{hostName} is streaming"),
new("og:description", ev.GetFirstTagValue("title") ?? ""),
new("og:image",
ev.GetFirstTagValue("image") ?? hostProfile?.Picture ?? $"https://robohash.v0l.io/{ev.PubKey.ToHex()}.png"),
new("og:video", stream),
new("og:video:secure_url", stream),
new("og:video:type", "application/vnd.apple.mpegurl"),
});
break;
}
case 1_313:
{
var stream = ev.GetFirstTagValue("r")!;
ret.AddRange(new KeyValuePair<string, string>[]
{
new("og:type", "video.other"),
new("og:title", $"{name} created a clip"),
new("og:description", ev.GetFirstTagValue("title") ?? ""),
new("og:image",
ev.GetFirstTagValue("image") ?? profile?.Picture ?? $"https://robohash.v0l.io/{ev.PubKey.ToHex()}.png"),
new("og:video", stream),
new("og:video:secure_url", stream),
new("og:video:type", "video/mp4"),
});
break;
}
default:
{
const int maxLen = 160;
var trimmedContent = ev.Content.Length > maxLen ? ev.Content[..maxLen] : ev.Content;
var titleContent = $"{profile}: {trimmedContent}";
ret.AddRange(new KeyValuePair<string, string>[]
{
new("og:type", "article"),
new("og:title", titleContent),
new("og:description", ""),
new("og:image", profile?.Picture ?? $"https://robohash.v0l.io/{ev.PubKey.ToHex()}.png"),
new("og:article:published_time", ev.Created.ToString("o")),
new("og:article:author:username", profile?.Name ?? ""),
});
break;
}
}
return ret;
}
private async Task<CachedMeta?> GetProfileMeta(CompactProfile? profile)
{
var titleContent = $"{profile?.Name ?? "Nostrich"}'s Profile";
var aboutContent = profile?.About?.Length > 160 ? profile.About[..160] : profile?.About ?? "";
var imageContent = profile?.Picture ?? "https://snort.social/nostrich_512.png";
return new CachedMeta
{
Title = titleContent,
Description = aboutContent,
Image = imageContent,
Profile = profile
};
}
private async Task<IDocument?> InjectTags(string html, List<HeadElement> tags)
{
var config = Configuration.Default;
var context = BrowsingContext.New(config);
var doc = await context.OpenAsync(c => c.Content(html));
foreach (var ex in tags)
{
var tag = doc.CreateElement(ex.Element);
foreach (var attr in ex.Attributes)
{
tag.SetAttribute(attr.Key, attr.Value);
}
doc.Head?.AppendChild(tag);
}
return doc;
}
private List<HeadElement> MetaTagsToElements(List<KeyValuePair<string, string>> tags)
{
var ret = new List<HeadElement>();
foreach (var tag in tags)
{
ret.Add(new("meta", [
new("property", tag.Key),
new("content", tag.Value)
]));
}
return ret;
}
record HeadElement(string Element, List<KeyValuePair<string, string>> Attributes);
private async Task<NostrIdentifier?> TryParseNip05(string id, CancellationToken cts)
{
try
{
if (id.Contains("@"))
{
var idSplit = id.Split("@");
var url = new Uri($"https://{idSplit[1]}/.well-known/nostr.json?name={Uri.EscapeDataString(idSplit[0])}");
var json = await _httpClient.GetStringAsync(url, cts);
var parsed = JsonConvert.DeserializeObject<NostrJson>(json);
var match = parsed?.Names?.FirstOrDefault(a => a.Key.Equals(idSplit[0], StringComparison.CurrentCultureIgnoreCase));
if (match.HasValue && !string.IsNullOrEmpty(match.Value.Value))
{
return new NostrProfileIdentifier(match.Value.Value.ToLower(), null);
}
}
}
catch (Exception ex)
{
_logger.LogWarning("Failed to parse nostr address {id} {msg}", id, ex.Message);
}
return default;
}
class NostrJson
{
[JsonProperty("names")]
public Dictionary<string, string>? Names { get; init; } = new();
}
}
[ProtoContract]
class CachedMeta
{
[ProtoMember(1)]
public string? Title { get; init; }
[ProtoMember(2)]
public string? Description { get; init; }
[ProtoMember(3)]
public string? Image { get; init; }
[ProtoMember(4)]
public CompactProfile? Profile { get; init; }
[ProtoMember(5)]
public CompactEvent? Event { get; init; }
}