package main import ( "context" "encoding/json" "fmt" "html" "html/template" "math/rand" "net/http" "regexp" "slices" "strconv" "strings" "sync" "time" "github.com/microcosm-cc/bluemonday" "github.com/puzpuzpuz/xsync/v3" "mvdan.cc/xurls/v2" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip19" ) const ( XML_HEADER = "\n" THIN_SPACE = '\u2009' INVISIBLE_SPACE = '\U0001D17A' ) var ( urlSuffixMatcher = regexp.MustCompile(`[\w-_.]+\.[\w-_.]+(\/[\/\w]*)?$`) nostrEveryMatcher = regexp.MustCompile(`nostr:((npub|note|nevent|nprofile|naddr)1[a-z0-9]+)\b`) nostrNoteNeventMatcher = regexp.MustCompile(`(?:^||\s)nostr:((note|nevent|naddr)1[a-z0-9]+)\b(?:\s||$)`) 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|avif)((\?|\#).*)?$`) videoExtensionMatcher = regexp.MustCompile(`.*\.(mp4|ogg|webm|mov)((\?|\#).*)?$`) urlRegex = xurls.Strict() ) 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", 1311: "Live Chat Message", 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", 30818: "Wiki article", 30311: "Live Event", } 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", 1311: "53", 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", 30818: "54", 30311: "53", } type Style string const ( StyleTelegram Style = "telegram" StyleTwitter = "twitter" StyleFacebook = "facebook" // Both Facebook and Instagram StyleIOS = "ios" StyleAndroid = "android" StyleMattermost = "mattermost" StyleSlack = "slack" StyleDiscord = "discord" StyleWhatsapp = "whatsapp" StyleIframely = "iframely" StyleNormal = "normal" StyleUnknown = "unknown" ) func getPreviewStyle(r *http.Request) Style { if style := r.URL.Query().Get("style"); style != "" { // debug mode return Style(style) } ua := strings.ToLower(r.Header.Get("User-Agent")) accept := r.Header.Get("Accept") switch { case strings.Contains(ua, "telegrambot"): return StyleTelegram case strings.Contains(ua, "twitterbot"): return StyleTwitter case strings.Contains(ua, "facebookexternalhit"): return StyleFacebook case strings.Contains(ua, "iphone"), strings.Contains(ua, "ipad"), strings.Contains(ua, "ipod"): return StyleIOS case strings.Contains(ua, "android"): return StyleAndroid case strings.Contains(ua, "mattermost"): return StyleMattermost case strings.Contains(ua, "slack"): return StyleSlack case strings.Contains(ua, "discord"): return StyleDiscord case strings.Contains(ua, "whatsapp"): return StyleWhatsapp case strings.Contains(ua, "iframely"): return StyleIframely case strings.Contains(accept, "text/html"): return StyleNormal default: return StyleUnknown } } func replaceURLsWithTags(input string, imageReplacementTemplate, videoReplacementTemplate string, skipLinks bool) 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 => ` ` // or markdown !()[...] tags for further processing => `` return fmt.Sprintf(imageReplacementTemplate, match) case videoExtensionMatcher.MatchString(match): // Match and replace video URLs with a custom replacement // Usually is html => ` ` // or markdown !()[...] tags for further processing => `` return fmt.Sprintf(videoReplacementTemplate, match) default: if skipLinks { return match } else { return "" + match + "" } } }) } func replaceNostrURLsWithHTMLTags(matcher *regexp.Regexp, input string) string { // match and replace npup1, nprofile1, note1, nevent1, etc names := xsync.NewMapOf[string, string]() wg := sync.WaitGroup{} // first we run it without waiting for the results of getNameFromNip19() as they will be async for _, match := range matcher.FindAllString(input, len(input)+1) { nip19 := match[len("nostr:"):] if strings.HasPrefix(nip19, "npub1") || strings.HasPrefix(nip19, "nprofile1") { ctx, cancel := context.WithTimeout(context.Background(), time.Second*4) defer cancel() wg.Add(1) go func() { name, _ := getNameFromNip19(ctx, nip19) names.Store(nip19, name) wg.Done() }() } } // in the second time now that we got all the names we actually perform replacement wg.Wait() return matcher.ReplaceAllStringFunc(input, func(match string) string { nip19 := match[len("nostr:"):] firstChars := nip19[:8] lastChars := nip19[len(nip19)-4:] if strings.HasPrefix(nip19, "npub1") || strings.HasPrefix(nip19, "nprofile1") { name, _ := names.Load(nip19) return fmt.Sprintf(`%s (%s)`, nip19, name, firstChars+"…"+lastChars) } else { return fmt.Sprintf(`%s`, nip19, firstChars+"…"+lastChars) } }) } 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:"):] firstChars := nip19[:8] lastChars := nip19[len(nip19)-4:] if strings.HasPrefix(nip19, "npub1") || strings.HasPrefix(nip19, "nprofile1") { return "@" + firstChars + "…" + lastChars } else { return "#" + firstChars + "…" + lastChars } }) } func getNameFromNip19(ctx context.Context, nip19code string) (string, bool) { metadata, _ := sys.FetchProfileFromInput(ctx, nip19code) if metadata.Name == "" { return nip19code, false } return metadata.Name, true } // replaces an npub/nprofile with the name of the author, if possible. // meant to be used when plaintext is expected, not formatted HTML. func replaceUserReferencesWithNames(ctx context.Context, input []string, prefix 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) nip19code := submatch[1] name, ok := getNameFromNip19(ctx, nip19code) if ok { return prefix + strings.ReplaceAll(name, " ", string(THIN_SPACE)) } return nip19code[0:10] + "…" + nip19code[len(nip19code)-5:] }) } 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() quotes := xsync.NewMapOf[string, string]() wg := sync.WaitGroup{} // first we run it without waiting for the results of getEvent() as they will be async for _, submatches := range nostrNoteNeventMatcher.FindAllStringSubmatch(input, len(input)+1) { nip19 := submatches[1] ctx, cancel := context.WithTimeout(context.Background(), time.Second*4) defer cancel() wg.Add(1) go func() { event, _, err := getEvent(ctx, nip19) if err == nil { quotedEvent := basicFormatting(submatches[0], false, usingTelegramInstantView, false) var content string if event.Kind == 30023 { content = mdToHTML(event.Content, usingTelegramInstantView) } else { content = basicFormatting(event.Content, false, usingTelegramInstantView, false) } content = fmt.Sprintf( `quoting %s %s `, quotedEvent, content) quotes.Store(submatches[0], content) } wg.Done() }() } // in the second time now that we got all the quoted events we actually perform replacement wg.Wait() return nostrNoteNeventMatcher.ReplaceAllStringFunc(input, func(match string) string { quote, ok := quotes.Load(match) if !ok { return match } return quote }) } func linkQuotes(input string) string { return nostrNoteNeventMatcher.ReplaceAllStringFunc(input, func(match string) string { nip19 := match[len("nostr:"):] firstChars := nip19[:8] lastChars := nip19[len(nip19)-4:] return fmt.Sprintf(`%s`, nip19, firstChars+"…"+lastChars) }) } func sanitizeXSS(html string) string { p := bluemonday.UGCPolicy() p.AllowStyling() p.RequireNoFollowOnLinks(false) p.AllowElements("video", "source") p.AllowAttrs("controls", "width").OnElements("video") p.AllowAttrs("src", "width").OnElements("source") return p.Sanitize(html) } func basicFormatting(input string, skipNostrEventLinks bool, usingTelegramInstantView bool, skipLinks bool) string { nostrMatcher := nostrEveryMatcher if skipNostrEventLinks { nostrMatcher = nostrNpubNprofileMatcher } imageReplacementTemplate := ` ` videoReplacementTemplate := `` if usingTelegramInstantView { // telegram instant view doesn't like when there is an image inside a blockquote (like ) // 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 tags on mdToHtml imageReplacementTemplate = "" + imageReplacementTemplate + "" videoReplacementTemplate = "" + videoReplacementTemplate + "" } lines := strings.Split(input, "\n") for i, line := range lines { line = replaceURLsWithTags(line, imageReplacementTemplate, videoReplacementTemplate, skipLinks) line = replaceNostrURLsWithHTMLTags(nostrMatcher, line) lines[i] = line } return strings.Join(lines, "") } 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, "") } func unique(strSlice []string) []string { if len(strSlice) == 0 { return strSlice } slices.Sort(strSlice) j := 0 for i := 1; i < len(strSlice); i++ { if strSlice[j] != strSlice[i] { j++ strSlice[j] = strSlice[i] } } return strSlice[:j+1] } func trimProtocolAndEndingSlash(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 '/' relay = strings.TrimSuffix(relay, "/") return relay } func normalizeWebsiteURL(u string) string { if strings.HasPrefix(u, "http") { return u } return "https://" + u } func limitAt[V any](list []V, n int) []V { if len(list) < n { return list } return list[0:n] } func humanDate(createdAt nostr.Timestamp) string { ts := createdAt.Time() now := time.Now() if ts.Before(now.AddDate(0, -9, 0)) { return ts.UTC().Format("02 Jan 2006") } else if ts.Before(now.AddDate(0, 0, -6)) { return ts.UTC().Format("Jan _2") } else { return ts.UTC().Format("Mon, Jan _2 15:04 UTC") } } func getRandomRelay() string { if serial == 0 { serial = rand.Intn(len(sys.FallbackRelays)) } serial = (serial + 1) % len(sys.FallbackRelays) return sys.FallbackRelays[serial] } func maxIndex(slice []int) int { maxIndex := -1 maxVal := 0 for i, val := range slice { if val > maxVal { maxVal = val maxIndex = i } } return maxIndex } func getUTCOffset(loc *time.Location) string { // Get the offset from UTC _, offset := time.Now().In(loc).Zone() // Calculate the offset in hours offsetHours := offset / 3600 // Format the UTC offset string sign := "+" if offsetHours < 0 { sign = "-" offsetHours = -offsetHours } return fmt.Sprintf("UTC%s%d", sign, offsetHours) } func toJSONHTML(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"` } tagsHTML += "\n " // if it's tagging another event, pubkey or address, make it a clickable link linkCls := "underline underline-offset-4 text-amber-700 dark:text-amber-100 hover:text-amber-600 dark:hover:text-amber-200" if i == 1 && tag[0] == "e" && nostr.IsValid32ByteHex(item) { var relayHints []string var authorHint string if len(tag) > 2 { relayHints = []string{tag[2]} if len(tag) > 4 { authorHint = tag[4] } } nevent, _ := nip19.EncodeEvent(item, relayHints, authorHint) tagsHTML += `"` + item + `"` } else if spl := strings.Split(item, ":"); i == 1 && tag[0] == "a" && len(spl) == 3 && nostr.IsValidPublicKey(spl[1]) { var relayHints []string if len(tag) > 2 { relayHints = []string{tag[2]} } kind, _ := strconv.Atoi(spl[0]) naddr, _ := nip19.EncodeEntity(spl[1], kind, spl[2], relayHints) tagsHTML += `"` + item + `"` } else if i == 1 && strings.ToLower(tag[0]) == "p" && nostr.IsValidPublicKey(item) { var relayHints []string if len(tag) > 2 { relayHints = []string{tag[2]} } nprofile, _ := nip19.EncodeProfile(item, relayHints) tagsHTML += `"` + item + `"` } else { // otherwise just print normally itemJSON, _ := json.Marshal(item) tagsHTML += 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( `{ "id": "%s", "pubkey": "%s", "created_at": %d, "kind": %d, "tags": %s, "content": %s, "sig": "%s" }`, evt.ID, evt.PubKey, evt.CreatedAt, evt.Kind, tagsHTML, html.EscapeString(string(contentJSON)), evt.Sig), ) } func isValidShortcode(s string) bool { for _, r := range s { if !('a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' || '0' <= r && r <= '9' || r == '_') { return false } } return true }
quoting %s %s
tags on mdToHtml imageReplacementTemplate = "" + imageReplacementTemplate + "
" videoReplacementTemplate = "
" } lines := strings.Split(input, "\n") for i, line := range lines { line = replaceURLsWithTags(line, imageReplacementTemplate, videoReplacementTemplate, skipLinks) line = replaceNostrURLsWithHTMLTags(nostrMatcher, line) lines[i] = line } return strings.Join(lines, "") } 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, "") } func unique(strSlice []string) []string { if len(strSlice) == 0 { return strSlice } slices.Sort(strSlice) j := 0 for i := 1; i < len(strSlice); i++ { if strSlice[j] != strSlice[i] { j++ strSlice[j] = strSlice[i] } } return strSlice[:j+1] } func trimProtocolAndEndingSlash(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 '/' relay = strings.TrimSuffix(relay, "/") return relay } func normalizeWebsiteURL(u string) string { if strings.HasPrefix(u, "http") { return u } return "https://" + u } func limitAt[V any](list []V, n int) []V { if len(list) < n { return list } return list[0:n] } func humanDate(createdAt nostr.Timestamp) string { ts := createdAt.Time() now := time.Now() if ts.Before(now.AddDate(0, -9, 0)) { return ts.UTC().Format("02 Jan 2006") } else if ts.Before(now.AddDate(0, 0, -6)) { return ts.UTC().Format("Jan _2") } else { return ts.UTC().Format("Mon, Jan _2 15:04 UTC") } } func getRandomRelay() string { if serial == 0 { serial = rand.Intn(len(sys.FallbackRelays)) } serial = (serial + 1) % len(sys.FallbackRelays) return sys.FallbackRelays[serial] } func maxIndex(slice []int) int { maxIndex := -1 maxVal := 0 for i, val := range slice { if val > maxVal { maxVal = val maxIndex = i } } return maxIndex } func getUTCOffset(loc *time.Location) string { // Get the offset from UTC _, offset := time.Now().In(loc).Zone() // Calculate the offset in hours offsetHours := offset / 3600 // Format the UTC offset string sign := "+" if offsetHours < 0 { sign = "-" offsetHours = -offsetHours } return fmt.Sprintf("UTC%s%d", sign, offsetHours) } func toJSONHTML(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"` } tagsHTML += "\n " // if it's tagging another event, pubkey or address, make it a clickable link linkCls := "underline underline-offset-4 text-amber-700 dark:text-amber-100 hover:text-amber-600 dark:hover:text-amber-200" if i == 1 && tag[0] == "e" && nostr.IsValid32ByteHex(item) { var relayHints []string var authorHint string if len(tag) > 2 { relayHints = []string{tag[2]} if len(tag) > 4 { authorHint = tag[4] } } nevent, _ := nip19.EncodeEvent(item, relayHints, authorHint) tagsHTML += `"` + item + `"` } else if spl := strings.Split(item, ":"); i == 1 && tag[0] == "a" && len(spl) == 3 && nostr.IsValidPublicKey(spl[1]) { var relayHints []string if len(tag) > 2 { relayHints = []string{tag[2]} } kind, _ := strconv.Atoi(spl[0]) naddr, _ := nip19.EncodeEntity(spl[1], kind, spl[2], relayHints) tagsHTML += `"` + item + `"` } else if i == 1 && strings.ToLower(tag[0]) == "p" && nostr.IsValidPublicKey(item) { var relayHints []string if len(tag) > 2 { relayHints = []string{tag[2]} } nprofile, _ := nip19.EncodeProfile(item, relayHints) tagsHTML += `"` + item + `"` } else { // otherwise just print normally itemJSON, _ := json.Marshal(item) tagsHTML += 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( `{ "id": "%s", "pubkey": "%s", "created_at": %d, "kind": %d, "tags": %s, "content": %s, "sig": "%s" }`, evt.ID, evt.PubKey, evt.CreatedAt, evt.Kind, tagsHTML, html.EscapeString(string(contentJSON)), evt.Sig), ) } func isValidShortcode(s string) bool { for _, r := range s { if !('a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' || '0' <= r && r <= '9' || r == '_') { return false } } return true }