271 lines
9.7 KiB
C#
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; }
|
|
}
|