Files
njump/utils.go
2023-10-28 14:10:22 +02:00

543 lines
17 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
html "html"
"html/template"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
mdhtml "github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"github.com/microcosm-cc/bluemonday"
"mvdan.cc/xurls/v2"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip10"
"github.com/nbd-wtf/go-nostr/nip19"
)
const XML_HEADER = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
var (
urlSuffixMatcher = regexp.MustCompile(`[\w-_.]+\.[\w-_.]+(\/[\/\w]*)?$`)
nostrEveryMatcher = regexp.MustCompile(`nostr:((npub|note|nevent|nprofile|naddr)1[a-z0-9]+)\b`)
nostrNoteNeventMatcher = regexp.MustCompile(`nostr:((note|nevent)1[a-z0-9]+)\b`)
nostrNpubNprofileMatcher = regexp.MustCompile(`nostr:((npub|nprofile)1[a-z0-9]+)\b`)
urlMatcher = func() *regexp.Regexp {
// hack to only allow these schemes while still using this library
xurls.Schemes = []string{"https"}
xurls.SchemesNoAuthority = []string{"blob"}
xurls.SchemesUnofficial = []string{"http"}
return xurls.Strict()
}()
imageExtensionMatcher = regexp.MustCompile(`.*\.(png|jpg|jpeg|gif|webp)(\?.*)?$`)
videoExtensionMatcher = regexp.MustCompile(`.*\.(mp4|ogg|webm|mov)(\?.*)?$`)
)
var kindNames = map[int]string{
0: "Metadata",
1: "Short Text Note",
2: "Recommend Relay",
3: "Contacts",
4: "Encrypted Direct Messages",
5: "Event Deletion",
6: "Reposts",
7: "Reaction",
8: "Badge Award",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1063: "File Metadata",
1984: "Reporting",
9734: "Zap Request",
9735: "Zap",
10000: "Mute List",
10001: "Pin List",
10002: "Relay List Metadata",
13194: "Wallet Info",
22242: "Client Authentication",
23194: "Wallet Request",
23195: "Wallet Response",
24133: "Nostr Connect",
30000: "Categorized People List",
30001: "Categorized Bookmark List",
30008: "Profile Badges",
30009: "Badge Definition",
30017: "Create or update a stall",
30018: "Create or update a product",
30023: "Long-form Content",
30078: "Application-specific Data",
}
var kindNIPs = map[int]string{
0: "01",
1: "01",
2: "01",
3: "02",
4: "04",
5: "09",
6: "18",
7: "25",
8: "58",
40: "28",
41: "28",
42: "28",
43: "28",
44: "28",
1063: "94",
1984: "56",
9734: "57",
9735: "57",
10000: "51",
10001: "51",
10002: "65",
13194: "47",
22242: "42",
23194: "47",
23195: "47",
24133: "46",
30000: "51",
30001: "51",
30008: "58",
30009: "58",
30017: "15",
30018: "15",
30023: "23",
30078: "78",
}
type ClientReference struct {
ID string
Name string
URL string
}
func generateClientList(code string, event *nostr.Event) []ClientReference {
if event.Kind == 1 || event.Kind == 6 {
return []ClientReference{
{ID: "native", Name: "your native client", URL: "nostr:" + code},
{ID: "snort", Name: "Snort", URL: "https://Snort.social/e/" + code},
{ID: "nostrudel", Name: "Nostrudel", URL: "https://nostrudel.ninja/#/n/" + code},
{ID: "satellite", Name: "Satellite", URL: "https://satellite.earth/thread/" + event.ID},
{ID: "coracle", Name: "Coracle", URL: "https://coracle.social/" + code},
{ID: "primal", Name: "Primal", URL: "https://primal.net/thread/" + event.ID},
{ID: "nostter", Name: "Nostter", URL: "https://nostter.vercel.app/" + code},
{ID: "highlighter", Name: "Highlighter", URL: "https://highlighter.com/a/" + code},
{ID: "iris", Name: "Iris", URL: "https://iris.to/" + code},
}
} else if event.Kind == 0 {
return []ClientReference{
{ID: "native", Name: "your native client", URL: "nostr:" + code},
{ID: "nosta", Name: "Nosta", URL: "https://nosta.me/" + code},
{ID: "snort", Name: "Snort", URL: "https://snort.social/p/" + code},
{ID: "satellite", Name: "Satellite", URL: "https://satellite.earth/@" + code},
{ID: "coracle", Name: "Coracle", URL: "https://coracle.social/" + code},
{ID: "primal", Name: "Primal", URL: "https://primal.net/profile/" + event.PubKey},
{ID: "nostrudel", Name: "Nostrudel", URL: "https://nostrudel.ninja/#/u/" + code},
{ID: "nostter", Name: "Nostter", URL: "https://nostter.vercel.app/" + code},
{ID: "iris", Name: "Iris", URL: "https://iris.to/" + code},
}
} else if event.Kind == 30023 || event.Kind == 30024 {
return []ClientReference{
{ID: "native", Name: "your native client", URL: "nostr:" + code},
{ID: "yakihonne", Name: "YakiHonne", URL: "https://yakihonne.com/article/" + code},
{ID: "habla", Name: "Habla", URL: "https://habla.news/a/" + code},
{ID: "highlighter", Name: "Highlighter", URL: "https://highlighter.com/a/" + code},
{ID: "blogstack", Name: "Blogstack", URL: "https://blogstack.io/" + code},
}
}
return nil
}
func generateRelayBrowserClientList(host string) []ClientReference {
return []ClientReference{
{ID: "coracle", Name: "Coracle", URL: "https://coracle.social/relays/" + host},
}
}
func getPreviewStyle(r *http.Request) string {
if style := r.URL.Query().Get("style"); style != "" {
// debug mode
return style
}
ua := strings.ToLower(r.Header.Get("User-Agent"))
accept := r.Header.Get("Accept")
switch {
case strings.Contains(ua, "telegrambot"):
return "telegram"
case strings.Contains(ua, "twitterbot"):
return "twitter"
case strings.Contains(ua, "mattermost"):
return "mattermost"
case strings.Contains(ua, "slack"):
return "slack"
case strings.Contains(ua, "discord"):
return "discord"
case strings.Contains(ua, "whatsapp"):
return "whatsapp"
case strings.Contains(ua, "iframely"):
return "iframely"
case strings.Contains(accept, "text/html"):
return "normal"
default:
return "unknown"
}
}
func getParentNevent(event *nostr.Event) string {
parentNevent := ""
replyTag := nip10.GetImmediateReply(event.Tags)
if replyTag != nil {
relay := ""
if len(*replyTag) > 2 {
relay = (*replyTag)[2]
} else {
relay = ""
}
parentNevent, _ = nip19.EncodeEvent((*replyTag)[1], []string{relay}, "")
}
return parentNevent
}
// Rendering functions
// ### ### ### ### ### ### ### ### ### ### ###
func replaceURLsWithTags(input string, imageReplacementTemplate, videoReplacementTemplate string) string {
return urlMatcher.ReplaceAllStringFunc(input, func(match string) string {
switch {
case imageExtensionMatcher.MatchString(match):
// Match and replace image URLs with a custom replacement
// Usually is html <img> => ` <img src="%s" alt=""> `
// or markdown !()[...] tags for further processing => `![](%s)`
return fmt.Sprintf(imageReplacementTemplate, match)
case videoExtensionMatcher.MatchString(match):
// Match and replace video URLs with a custom replacement
// Usually is html <video> => ` <video controls width="100%%"><source src="%s"></video> `
// or markdown !()[...] tags for further processing => `![](%s)`
return fmt.Sprintf(videoReplacementTemplate, match)
default:
return "<a href=\"" + match + "\">" + match + "</a>"
}
})
}
func replaceNostrURLsWithTags(matcher *regexp.Regexp, input string) string {
// match and replace npup1, nprofile1, note1, nevent1, etc
return matcher.ReplaceAllStringFunc(input, func(match string) string {
nip19 := match[len("nostr:"):]
first_chars := nip19[:8]
last_chars := nip19[len(nip19)-4:]
if strings.HasPrefix(nip19, "npub1") || strings.HasPrefix(nip19, "nprofile1") {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*4)
defer cancel()
name := getNameFromNip19(ctx, nip19)
return fmt.Sprintf(`<a href="/%s" class="bg-lavender dark:bg-garnet px-1"><span class="font-bold">%s</span> (<span class="italic">%s</span>)</a>`, nip19, name, first_chars+"…"+last_chars)
} else {
return fmt.Sprintf(`<a href="/%s" class="bg-lavender dark:bg-garnet px-1">%s</a>`, nip19, first_chars+"…"+last_chars)
}
})
}
func shortenNostrURLs(input string) string {
// match and replace npup1, nprofile1, note1, nevent1, etc
return nostrEveryMatcher.ReplaceAllStringFunc(input, func(match string) string {
nip19 := match[len("nostr:"):]
first_chars := nip19[:8]
last_chars := nip19[len(nip19)-4:]
if strings.HasPrefix(nip19, "npub1") || strings.HasPrefix(nip19, "nprofile1") {
return "@" + first_chars + "…" + last_chars
} else {
return "#" + first_chars + "…" + last_chars
}
})
}
func getNameFromNip19(ctx context.Context, nip19 string) string {
author, _, err := getEvent(ctx, nip19, nil)
if err != nil {
return nip19
}
metadata, err := nostr.ParseMetadata(*author)
if err != nil {
return nip19
}
if metadata.Name == "" {
return nip19
}
return metadata.Name
}
// replaces an npub/nprofile with the name of the author, if possible
func replaceUserReferencesWithNames(ctx context.Context, input []string) []string {
// Match and replace npup1 or nprofile1
ctx, cancel := context.WithTimeout(ctx, time.Second*3)
defer cancel()
for i, line := range input {
input[i] = nostrNpubNprofileMatcher.ReplaceAllStringFunc(line, func(match string) string {
submatch := nostrNpubNprofileMatcher.FindStringSubmatch(match)
nip19 := submatch[1]
return getNameFromNip19(ctx, nip19)
})
}
return input
}
// replace nevent and note with their text, HTML-formatted
func renderQuotesAsHTML(ctx context.Context, input string, usingTelegramInstantView bool) string {
ctx, cancel := context.WithTimeout(ctx, time.Second*3)
defer cancel()
return nostrNoteNeventMatcher.ReplaceAllStringFunc(input, func(match string) string {
submatch := nostrNoteNeventMatcher.FindStringSubmatch(match)
nip19 := submatch[1]
event, _, err := getEvent(ctx, nip19, nil)
if err != nil {
log.Warn().Str("nip19", nip19).Msg("failed to get nip19")
return nip19
}
content := fmt.Sprintf(
`<blockquote class="pl-4 pr-0 pt-0 pb-2 border-l-05rem border-l-strongpink border-solid"><div class="-ml-4 bg-gray-100 dark:bg-zinc-800 mr-0 mt-0 mb-4 pl-4 pr-2 py-2">quoting %s </div> %s </blockquote>`, match, event.Content)
return basicFormatting(content, false, usingTelegramInstantView)
})
}
func sanitizeXSS(html string) string {
p := bluemonday.UGCPolicy()
p.AllowStyling()
p.RequireNoFollowOnLinks(false)
p.AllowElements("video", "source", "iframe")
p.AllowAttrs("controls", "width").OnElements("video")
p.AllowAttrs("src", "width").OnElements("source")
p.AllowAttrs("src", "frameborder").OnElements("iframe")
return p.Sanitize(html)
}
func basicFormatting(input string, skipNostrEventLinks bool, usingTelegramInstantView bool) string {
nostrMatcher := nostrEveryMatcher
if skipNostrEventLinks {
nostrMatcher = nostrNpubNprofileMatcher
}
imageReplacementTemplate := ` <img src="%s"> `
if usingTelegramInstantView {
// telegram instant view doesn't like when there is an image inside a blockquote (like <p><img></p>)
// so we use this custom thing to stop all blockquotes before the images, print the images then
// start a new blockquote afterwards -- we do the same with the markdown renderer for <p> tags on mdToHtml
imageReplacementTemplate = "</blockquote>" + imageReplacementTemplate + "<blockquote>"
}
lines := strings.Split(input, "\n")
for i, line := range lines {
line = replaceURLsWithTags(line,
imageReplacementTemplate,
`<video controls width="100%%"><source src="%s"></video>`,
)
line = replaceNostrURLsWithTags(nostrMatcher, line)
lines[i] = line
}
return strings.Join(lines, "<br/>")
}
func previewNotesFormatting(input string) string {
lines := strings.Split(input, "\n")
var processedLines []string
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
processedLine := shortenNostrURLs(line)
processedLines = append(processedLines, processedLine)
}
return strings.Join(processedLines, "<br/>")
}
func mdToHTML(md string, usingTelegramInstantView bool) string {
md = strings.ReplaceAll(md, "\u00A0", " ")
md = replaceNostrURLsWithTags(nostrEveryMatcher, md)
// create markdown parser with extensions
p := parser.NewWithExtensions(
parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock | parser.Footnotes)
doc := p.Parse([]byte(md))
var customNodeHook mdhtml.RenderNodeFunc = nil
if usingTelegramInstantView {
// telegram instant view really doesn't like when there is an image inside a paragraph (like <p><img></p>)
// so we use this custom thing to stop all paragraphs before the images, print the images then start a new
// paragraph afterwards.
customNodeHook = func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
if img, ok := node.(*ast.Image); ok {
if entering {
src := img.Destination
w.Write([]byte(`</p><img src="`))
mdhtml.EscLink(w, src)
w.Write([]byte(`" alt="`))
} else {
if img.Title != nil {
w.Write([]byte(`" title="`))
mdhtml.EscapeHTML(w, img.Title)
}
w.Write([]byte(`" /><p>`))
}
return ast.GoToNext, true
}
return ast.GoToNext, false
}
}
// create HTML renderer with extensions
opts := mdhtml.RendererOptions{
Flags: mdhtml.CommonFlags | mdhtml.HrefTargetBlank,
RenderNodeHook: customNodeHook,
}
renderer := mdhtml.NewRenderer(opts)
output := string(markdown.Render(doc, renderer))
// sanitize content
output = sanitizeXSS(output)
return output
}
func unique(strSlice []string) []string {
keys := make(map[string]bool)
list := []string{}
for _, entry := range strSlice {
if _, ok := keys[entry]; !ok {
keys[entry] = true
list = append(list, entry)
}
}
return list
}
func trimProtocol(relay string) string {
relay = strings.TrimPrefix(relay, "wss://")
relay = strings.TrimPrefix(relay, "ws://")
relay = strings.TrimPrefix(relay, "wss:/") // Some browsers replace upfront '//' with '/'
relay = strings.TrimPrefix(relay, "ws:/") // Some browsers replace upfront '//' with '/'
return relay
}
func normalizeWebsiteURL(u string) string {
if strings.HasPrefix(u, "http") {
return u
}
return "https://" + u
}
func loadNpubsArchive(ctx context.Context) {
log.Debug().Msg("refreshing the npubs archive")
contactsArchive := make([]string, 0, 500)
for _, pubkey := range trustedPubKeys {
ctx, cancel := context.WithTimeout(ctx, time.Second*4)
pubkeyContacts := contactsForPubkey(ctx, pubkey)
contactsArchive = append(contactsArchive, pubkeyContacts...)
cancel()
}
contactsArchive = unique(contactsArchive)
for _, contact := range contactsArchive {
log.Debug().Msgf("adding contact %s", contact)
cache.SetWithTTL("pa:"+contact, nil, time.Hour*24*90)
}
}
func loadRelaysArchive(ctx context.Context) {
log.Debug().Msg("refreshing the relays archive")
relaysArchive := make([]string, 0, 500)
for _, pubkey := range trustedPubKeys {
ctx, cancel := context.WithTimeout(ctx, time.Second*4)
pubkeyContacts := relaysForPubkey(ctx, pubkey)
relaysArchive = append(relaysArchive, pubkeyContacts...)
cancel()
}
relaysArchive = unique(relaysArchive)
for _, relay := range relaysArchive {
for _, excluded := range excludedRelays {
if strings.Contains(relay, excluded) {
log.Debug().Msgf("skipping relay %s", relay)
continue
}
}
if strings.Contains(relay, "/npub1") {
continue // skip relays with personalyzed query like filter.nostr.wine
}
log.Debug().Msgf("adding relay %s", relay)
cache.SetWithTTL("ra:"+relay, nil, time.Hour*24*7)
}
}
func eventToHTML(evt *nostr.Event) template.HTML {
tagsHTML := "["
for t, tag := range evt.Tags {
tagsHTML += "\n ["
for i, item := range tag {
cls := `"text-zinc-500 dark:text-zinc-50"`
if i == 0 {
cls = `"text-amber-500 dark:text-amber-200"`
}
itemJSON, _ := json.Marshal(item)
tagsHTML += "\n <span class=" + cls + ">" + html.EscapeString(string(itemJSON))
if i < len(tag)-1 {
tagsHTML += ","
} else {
tagsHTML += "\n "
}
}
tagsHTML += "]"
if t < len(evt.Tags)-1 {
tagsHTML += ","
} else {
tagsHTML += "\n "
}
}
tagsHTML += "]"
contentJSON, _ := json.Marshal(evt.Content)
keyCls := "text-purple-700 dark:text-purple-300"
return template.HTML(fmt.Sprintf(
`{
<span class="`+keyCls+`">"id":</span> <span class="text-zinc-500 dark:text-zinc-50">"%s"</span>,
<span class="`+keyCls+`">"pubkey":</span> <span class="text-zinc-500 dark:text-zinc-50">"%s"</span>,
<span class="`+keyCls+`">"created_at":</span> <span class="text-green-600">%d</span>,
<span class="`+keyCls+`">"kind":</span> <span class="text-amber-500 dark:text-amber-200">%d</span>,
<span class="`+keyCls+`">"tags":</span> %s,
<span class="`+keyCls+`">"content":</span> <span class="text-zinc-500 dark:text-zinc-50">%s</span>,
<span class="`+keyCls+`">"sig":</span> <span class="text-zinc-500 dark:text-zinc-50 content">"%s"</span>
}`, evt.ID, evt.PubKey, evt.CreatedAt, evt.Kind, tagsHTML, html.EscapeString(string(contentJSON)), evt.Sig),
)
}
func limitAt[V any](list []V, n int) []V {
if len(list) < n {
return list
}
return list[0:n]
}