package main import ( "context" "encoding/json" "fmt" "net/http" "regexp" "strings" "time" "github.com/gomarkdown/markdown" mdhtml "github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/parser" "github.com/microcosm-cc/bluemonday" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip10" "github.com/nbd-wtf/go-nostr/nip19" "github.com/pelletier/go-toml" ) 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", } var urlSuffixMatcher = regexp.MustCompile(`[\w-_.]+\.[\w-_.]+(\/[\/\w]*)?$`) type ClientReference struct { Name string URL string } func generateClientList(code string, event *nostr.Event) []ClientReference { if event.Kind == 1 || event.Kind == 6 { return []ClientReference{ {Name: "native client", URL: "nostr:" + code}, {Name: "Snort", URL: "https://Snort.social/e/" + code}, {Name: "Coracle", URL: "https://coracle.social/" + code}, {Name: "Satellite", URL: "https://satellite.earth/thread/" + event.ID}, {Name: "Agora", URL: "https://agorasocial.app/" + event.ID}, {Name: "Iris", URL: "https://iris.to/" + code}, {Name: "Yosup", URL: "https://yosup.app/thread/" + event.ID}, {Name: "Primal", URL: "https://primal.net/thread/" + event.ID}, {Name: "Nostr.band", URL: "https://nostr.band/" + code}, {Name: "Highlighter", URL: "https://highlighter.com/a/" + code}, } } else if event.Kind == 0 { return []ClientReference{ {Name: "Your native client", URL: "nostr:" + code}, {Name: "Nosta", URL: "https://nosta.me/" + code}, {Name: "Snort", URL: "https://snort.social/p/" + code}, {Name: "Coracle", URL: "https://coracle.social/" + code}, {Name: "Satellite", URL: "https://satellite.earth/@" + code}, {Name: "Agora", URL: "https://agorasocial.app/people/" + event.PubKey}, {Name: "Iris", URL: "https://iris.to/" + code}, {Name: "Yosup", URL: "https://yosup.app/profile/" + event.PubKey}, {Name: "Primal", URL: "https://primal.net/profile/" + event.PubKey}, {Name: "Nostr.band", URL: "https://nostr.band/" + code}, {Name: "Highlighter", URL: "https://highlighter.com/p/" + event.PubKey}, } } else if event.Kind == 30023 || event.Kind == 30024 { return []ClientReference{ {Name: "Your native client", URL: "nostr:" + code}, {Name: "YakiHonne", URL: "https://yakihonne.com/article/" + code}, {Name: "Habla", URL: "https://habla.news/a/" + code}, {Name: "Highlighter", URL: "https://highlighter.com/a/" + code}, {Name: "Blogstack", URL: "https://blogstack.io/" + code}, } } return nil } func mergeMaps[K comparable, V any](m1 map[K]V, m2 map[K]V) map[K]V { for k, v := range m2 { m1[k] = v } return m1 } func prettyJsonOrRaw(j string) string { var parsedContent any if err := json.Unmarshal([]byte(j), &parsedContent); err == nil { if t, err := toml.Marshal(parsedContent); err == nil && len(t) > 0 { return string(t) } } return j } func getPreviewStyle(r *http.Request) string { 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(accept, "text/html"): return "" 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 replateImageURLsWithTags(input string, replacement string) string { // Match and replace image URLs with a custom replacement // Usually is html => ` ` // or markdown !()[...] tags for further processing => `` var regex *regexp.Regexp imgsPattern := `\S*(\()?(https?://\S+(\.jpg|\.jpeg|\.png|\.webp|\.gif))\S*` regex = regexp.MustCompile(imgsPattern) input = regex.ReplaceAllStringFunc(input, func(match string) string { submatch := regex.FindStringSubmatch(match) if len(submatch) < 2 || strings.Contains(submatch[0], "](") { // Markdown  image return match } capturedGroup := submatch[2] replacement := fmt.Sprintf(replacement, capturedGroup) return replacement }) return input } func replateVideoURLsWithTags(input string, replacement string) string { // Match and replace video URLs with a custom replacement // Usually is html => ` ` // or markdown !()[...] tags for further processing => `` var regex *regexp.Regexp videoPattern := `\S*(https?://\S+(\.mp4|\.ogg|\.webm|.mov))\S*` regex = regexp.MustCompile(videoPattern) input = regex.ReplaceAllStringFunc(input, func(match string) string { submatch := regex.FindStringSubmatch(match) if len(submatch) < 2 { return match } capturedGroup := submatch[1] replacement := fmt.Sprintf(replacement, capturedGroup) return replacement }) return input } func replaceNostrURLsWithTags(input string) string { // Match and replace npup1, nprofile1, note1, nevent1, etc nostrRegexPattern := `\S*(nostr:)?((npub|note|nevent|nprofile|naddr)1[a-z0-9]+)\b` nostrRegex := regexp.MustCompile(nostrRegexPattern) input = nostrRegex.ReplaceAllStringFunc(input, func(match string) string { submatch := nostrRegex.FindStringSubmatch(match) if len(submatch) < 2 || strings.Contains(submatch[0], "/") { return match } capturedGroup := submatch[2] first6 := capturedGroup[:6] last6 := capturedGroup[len(capturedGroup)-6:] replacement := fmt.Sprintf(`%s`, capturedGroup, first6+"…"+last6) return replacement }) return input } func renderInlineMentions(input string) string { lines := strings.Split(input, "\n") var processedLines []string for _, line := range lines { nostrRegexPattern := `\S*(nostr:)?((note|nevent)1[a-z0-9]+)\b` nostrRegex := regexp.MustCompile(nostrRegexPattern) input = nostrRegex.ReplaceAllStringFunc(line, func(match string) string { submatch := nostrRegex.FindStringSubmatch(match) if len(submatch) < 2 || strings.Contains(submatch[0], "/") { return match } capturedGroup := submatch[2] replacement := "" ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) event, _ := getEvent(ctx, capturedGroup) cancel() replacement = fmt.Sprintf(`{blockquote} {div}From: %s {/div} %s {/blockquote}`, capturedGroup, event.Content) return replacement }) processedLines = append(processedLines, input) } return strings.Join(processedLines, "\n") } func replaceURLsWithTags(line string) string { var rline string rline = replateImageURLsWithTags(line, ` `) if rline != line { return rline } rline = replateVideoURLsWithTags(line, ``) if rline != line { return rline } line = replaceNostrURLsWithTags(line) // Match and replace other URLs with tags hrefRegexPattern := `\S*(https?://\S+)\S*` hrefRegex := regexp.MustCompile(hrefRegexPattern) line = hrefRegex.ReplaceAllString(line, `$1`) return line } 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) string { input = strings.ReplaceAll(input, "{blockquote}", "") input = strings.ReplaceAll(input, "{/blockquote}", "") input = strings.ReplaceAll(input, "{div}", "") input = strings.ReplaceAll(input, "{/div}", "") lines := strings.Split(input, "\n") var processedLines []string for _, line := range lines { processedLine := replaceURLsWithTags(line) processedLines = append(processedLines, processedLine) } return strings.Join(processedLines, "") } func mdToHTML(md string) string { md = strings.ReplaceAll(md, "\u00A0", " ") md = replateImageURLsWithTags(md, ``) md = replateVideoURLsWithTags(md, ``) md = replaceNostrURLsWithTags(md) // create markdown parser with extensions extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock | parser.Footnotes p := parser.NewWithExtensions(extensions) doc := p.Parse([]byte(md)) // create HTML renderer with extensions htmlFlags := mdhtml.CommonFlags | mdhtml.HrefTargetBlank opts := mdhtml.RendererOptions{Flags: htmlFlags} renderer := mdhtml.NewRenderer(opts) output := string(markdown.Render(doc, renderer)) // Sanitize content output = sanitizeXSS(output) return output } func titleize(s string) string { s = strings.Replace(s, "\r\n", " ", -1) s = strings.Replace(s, "\n", " ", -1) if len(s) <= 65 { return "\"" + s + "\"" } return "\"" + s[:64] + "…\"" } 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 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\n", 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\n", relay) continue } } if strings.Contains(relay, "/npub1") { continue // skip relays with personalyzed query like filter.nostr.wine } log.Debug().Msgf("adding relay %s\n", relay) cache.SetWithTTL("ra:"+relay, nil, time.Hour*24*7) } }
") input = strings.ReplaceAll(input, "{/blockquote}", "