clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / internal / daemon / social_browse.go
social_browse.go 17.9K
1package daemon23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "net/url"9 "sort"10 "strconv"11 "strings"12 "time"1314 "github.com/FelineStateMachine/tinyrelay/internal/event"15 "github.com/FelineStateMachine/tinyrelay/internal/storage"16)1718type socialBrowseRequest struct {19 Kind string `json:"kind"`20 Author string `json:"author"`21 Query string `json:"q"`22 Cursor string `json:"cursor"`23 Limit int `json:"limit"`24 ID string `json:"id"`25 Address string `json:"address"`26}2728type socialItem struct {29 ID string `json:"id"`30 Kind int `json:"kind"`31 Author string `json:"author"`32 Pubkey string `json:"pubkey"`33 Tags [][]string `json:"tags,omitempty"`34 CreatedAt int64 `json:"created_at"`35 Content string `json:"content"`36 Title string `json:"title,omitempty"`37 Summary string `json:"summary,omitempty"`38 Image string `json:"image,omitempty"`39 Address string `json:"address,omitempty"`40 URL string `json:"url,omitempty"`41 ReadPath string `json:"read_path"`42 AuthorName string `json:"author_name,omitempty"`43 AuthorPicture string `json:"author_picture,omitempty"`44 ReplyCount int `json:"reply_count"`45 Reactions []socialReaction `json:"reactions,omitempty"`46 ParentID string `json:"parent_id,omitempty"`47 ParentAuthor string `json:"parent_author,omitempty"`48 ParentKind int `json:"parent_kind,omitempty"`49 ParentAddress string `json:"parent_address,omitempty"`50 RootID string `json:"root_id,omitempty"`51 RootAuthor string `json:"root_author,omitempty"`52 RootKind int `json:"root_kind,omitempty"`53 RootAddress string `json:"root_address,omitempty"`54 ReadMinutes int `json:"read_minutes,omitempty"`55 Event event.Event `json:"event"`56}5758type socialReaction struct {59 Content string `json:"content"`60 Count int `json:"count"`61 Reacted bool `json:"reacted,omitempty"`62 Image string `json:"image,omitempty"`63}6465func socialBrowseMethod(method string) bool {66 return method == "browsesocial" || method == "browsesocialthread"67}6869func (t *Tenant) executeSocial(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {70 if !t.Policy().Features.Pages {71 return nil, errors.New("not found: social is disabled")72 }73 q := socialBrowseRequest{}74 if len(params) > 0 {75 if err := json.Unmarshal(params[0], &q); err != nil {76 return nil, fmt.Errorf("invalid: social parameters: %w", err)77 }78 }79 if q.Limit <= 0 || q.Limit > 100 {80 q.Limit = 3081 }82 if q.Author != "" && !socialHex(q.Author) {83 return nil, errors.New("invalid: social author")84 }85 if method == "browsesocialthread" {86 return t.browseSocialThread(ctx, actor, q)87 }88 return t.browseSocial(ctx, actor, q)89}9091func socialHex(value string) bool {92 return len(value) == 64 && strings.Trim(value, "0123456789abcdef") == ""93}94func socialAddress(row event.Event) string {95 if row.Kind == 30023 {96 return "30023:" + row.PubKey + ":" + event.Tag(row, "d")97 }98 return ""99}100func socialCursor(row event.Event) string {101 return collaborationCursor(collaborationItem{CreatedAt: row.CreatedAt, ID: row.ID})102}103func socialOlder(a, b event.Event) bool {104 if a.CreatedAt == b.CreatedAt {105 return a.ID < b.ID106 }107 return a.CreatedAt > b.CreatedAt108}109110func (t *Tenant) browseSocial(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {111 kinds := []int{1, 30023}112 switch q.Kind {113 case "", "all":114 case "notes":115 kinds = []int{1}116 case "articles":117 kinds = []int{30023}118 default:119 return nil, errors.New("invalid: social kind")120 }121 cursor, err := parseCollaborationCursor(q.Cursor)122 if err != nil {123 return nil, err124 }125 filter := event.Filter{Kinds: kinds}126 if q.Author != "" {127 filter.Authors = []string{q.Author}128 }129 rows := []event.Event{}130 next := ""131 session := browseSession(t, actor)132 for scan := 0; scan < 20; scan++ {133 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 100, Before: cursor})134 if err != nil {135 return nil, err136 }137 for _, row := range page.Events {138 cursor = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}139 if socialFeedRoot(row) && t.gate.CanSee(ctx, row, session, &filter) && socialMatches(row, q.Query) {140 rows = append(rows, row)141 }142 if len(rows) > q.Limit {143 break144 }145 }146 if len(rows) > q.Limit {147 next = socialCursor(rows[q.Limit-1])148 rows = rows[:q.Limit]149 break150 }151 if !page.More || len(page.Events) == 0 {152 break153 }154 if scan == 19 && cursor != nil {155 next = collaborationCursor(collaborationItem{CreatedAt: cursor.CreatedAt, ID: cursor.ID})156 }157 }158 items, err := t.socialItems(ctx, actor, rows)159 if err != nil {160 return nil, err161 }162 stats := map[string]int{"notes": 0, "articles": 0, "comments": 0}163 for _, item := range items {164 if item.Kind == 30023 {165 stats["articles"]++166 } else {167 stats["notes"]++168 }169 stats["comments"] += item.ReplyCount170 }171 return map[string]any{"items": items, "next_cursor": next, "stats": stats}, nil172}173174func socialFeedRoot(row event.Event) bool {175 if row.Kind == 30023 {176 return true177 }178 if row.Kind != 1 || event.Tag(row, "h") != "" {179 return false180 }181 ref := socialReference(row)182 return ref.parent == "" && ref.parentAddress == ""183}184func socialMatches(row event.Event, query string) bool {185 needle := strings.ToLower(strings.TrimSpace(query))186 return needle == "" || strings.Contains(strings.ToLower(row.Content+" "+event.Tag(row, "title")+" "+event.Tag(row, "summary")), needle)187}188189// socialEvents scans only the requested references and applies the same per-event190// visibility rules as relay subscriptions, including profiles and reactions.191func (t *Tenant) socialEvents(ctx context.Context, actor string, filters []event.Filter) ([]event.Event, error) {192 session := browseSession(t, actor)193 out := []event.Event{}194 seen := map[string]bool{}195 for _, filter := range filters {196 var cursor *storage.EventCursor197 for {198 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 200, Before: cursor})199 if err != nil {200 return nil, err201 }202 for _, row := range page.Events {203 if !seen[row.ID] && t.gate.CanSee(ctx, row, session, &filter) {204 seen[row.ID] = true205 out = append(out, row)206 }207 }208 if !page.More || len(page.Events) == 0 {209 break210 }211 last := page.Events[len(page.Events)-1]212 cursor = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}213 }214 }215 sort.Slice(out, func(i, j int) bool { return socialOlder(out[i], out[j]) })216 return out, nil217}218func socialActivityFilters(rows []event.Event) []event.Filter {219 ids, addresses := []string{}, []string{}220 for _, row := range rows {221 ids = append(ids, row.ID)222 if address := socialAddress(row); address != "" {223 addresses = append(addresses, address)224 }225 }226 filters := []event.Filter{{Kinds: []int{1, 7, 1111}, Tags: map[string][]string{"e": ids}}, {Kinds: []int{1111}, Tags: map[string][]string{"E": ids}}}227 if len(addresses) > 0 {228 filters = append(filters, event.Filter{Kinds: []int{1, 7, 1111}, Tags: map[string][]string{"a": addresses}}, event.Filter{Kinds: []int{1111}, Tags: map[string][]string{"A": addresses}})229 }230 return filters231}232func (t *Tenant) socialItems(ctx context.Context, actor string, rows []event.Event) ([]socialItem, error) {233 if len(rows) == 0 {234 return []socialItem{}, nil235 }236 extra, err := t.socialEvents(ctx, actor, socialActivityFilters(rows))237 if err != nil {238 return nil, err239 }240 items := socialItemsWithRefs(rows, extra, actor, nil)241 authors := []string{}242 seen := map[string]bool{}243 for _, row := range rows {244 if !seen[row.PubKey] {245 seen[row.PubKey] = true246 authors = append(authors, row.PubKey)247 }248 }249 profiles, err := t.socialEvents(ctx, actor, []event.Filter{{Kinds: []int{0}, Authors: authors}})250 if err != nil {251 return nil, err252 }253 names := map[string][2]string{}254 for _, profile := range profiles {255 var p struct {256 Name string `json:"name"`257 Display string `json:"display_name"`258 Picture string `json:"picture"`259 }260 if json.Unmarshal([]byte(profile.Content), &p) == nil {261 if p.Display == "" {262 p.Display = p.Name263 }264 names[profile.PubKey] = [2]string{p.Display, p.Picture}265 }266 }267 for i := range items {268 items[i].AuthorName, items[i].AuthorPicture = names[items[i].Pubkey][0], names[items[i].Pubkey][1]269 }270 return items, nil271}272273func socialItemsWithRefs(rows, extra []event.Event, actor string, _ map[string]string) []socialItem {274 items := make([]socialItem, len(rows))275 index := map[string]int{}276 for i, row := range rows {277 items[i] = socialItemFrom(row)278 index[row.ID] = i279 if a := socialAddress(row); a != "" {280 index[a] = i281 }282 }283 type reactionKey struct {284 index int285 author, content string286 }287 seen := map[reactionKey]bool{}288 counts := map[int]map[string]socialReaction{}289 for _, row := range extra {290 if row.Kind == 7 {291 ref := socialReference(row)292 target := ref.parent293 if _, ok := index[ref.parentAddress]; ok {294 target = ref.parentAddress295 }296 i, ok := index[target]297 if !ok {298 continue299 }300 content, image := socialReactionValue(row)301 key := reactionKey{i, row.PubKey, content + "\x00" + image}302 if seen[key] {303 continue304 }305 seen[key] = true306 if counts[i] == nil {307 counts[i] = map[string]socialReaction{}308 }309 variant := content + "\x00" + image310 r := counts[i][variant]311 r.Content = content312 r.Image = image313 r.Count++314 r.Reacted = r.Reacted || row.PubKey == actor315 counts[i][variant] = r316 } else if row.Kind == 1 || row.Kind == 1111 {317 for i, post := range rows {318 if socialCommentFor(row, post) {319 items[i].ReplyCount++320 }321 }322 }323 }324 for i, group := range counts {325 for _, r := range group {326 items[i].Reactions = append(items[i].Reactions, r)327 }328 sort.Slice(items[i].Reactions, func(a, b int) bool {329 if items[i].Reactions[a].Content == items[i].Reactions[b].Content {330 return items[i].Reactions[a].Image < items[i].Reactions[b].Image331 }332 return items[i].Reactions[a].Content < items[i].Reactions[b].Content333 })334 }335 return items336}337338// socialReactionValue applies NIP-30 custom emoji metadata only when the339// reaction content and emoji tag form a valid, safe pair. Invalid metadata is340// retained as ordinary reaction text so it cannot turn into an image URL.341func socialReactionValue(row event.Event) (string, string) {342 content := row.Content343 if content == "" {344 return "+", ""345 }346 if len(content) < 3 || content[0] != ':' || content[len(content)-1] != ':' {347 return content, ""348 }349 shortcode := content[1 : len(content)-1]350 if !socialEmojiShortcode(shortcode) {351 return content, ""352 }353 for _, tag := range row.Tags {354 if len(tag) < 3 || tag[0] != "emoji" || tag[1] != shortcode {355 continue356 }357 if socialEmojiURL(tag[2]) {358 return content, tag[2]359 }360 }361 return content, ""362}363364func socialEmojiShortcode(value string) bool {365 if value == "" {366 return false367 }368 for _, char := range value {369 if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' && char != '_' {370 return false371 }372 }373 return true374}375376func socialEmojiURL(value string) bool {377 parsed, err := url.Parse(value)378 return err == nil && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.Hostname() != "" && parsed.User == nil379}380381type socialRef struct {382 root, parent, rootAuthor, parentAuthor string383 rootKind, parentKind int384 rootAddress, parentAddress string385}386387func tagValue(tag []string, index int) string {388 if len(tag) > index {389 return tag[index]390 }391 return ""392}393func atoiTag(value string) int { n, _ := strconv.Atoi(value); return n }394func addressAuthor(address string) string {395 parts := strings.SplitN(address, ":", 3)396 if len(parts) == 3 {397 return parts[1]398 }399 return ""400}401func socialReference(row event.Event) socialRef {402 ref := socialRef{}403 if row.Kind == 1 {404 var legacy [][]string405 marked := false406 for _, tag := range row.Tags {407 if len(tag) < 2 {408 continue409 }410 if tag[0] == "a" {411 ref.parentAddress = tag[1]412 ref.rootAddress = tag[1]413 continue414 }415 if tag[0] != "e" {416 continue417 }418 switch tagValue(tag, 3) {419 case "root":420 ref.root, ref.rootAuthor = tag[1], tagValue(tag, 4)421 marked = true422 case "reply":423 ref.parent, ref.parentAuthor = tag[1], tagValue(tag, 4)424 marked = true425 case "":426 legacy = append(legacy, tag)427 }428 }429 if !marked && len(legacy) > 0 {430 ref.root = legacy[0][1]431 ref.parent = legacy[len(legacy)-1][1]432 }433 if ref.root == "" {434 ref.root, ref.rootAuthor = ref.parent, ref.parentAuthor435 }436 if ref.parent == "" {437 ref.parent, ref.parentAuthor = ref.root, ref.rootAuthor438 }439 ref.rootKind, ref.parentKind = 1, 1440 return ref441 }442 for _, tag := range row.Tags {443 if len(tag) < 2 {444 continue445 }446 switch tag[0] {447 case "E":448 ref.root, ref.rootAuthor = tag[1], tagValue(tag, 3)449 case "e":450 ref.parent, ref.parentAuthor = tag[1], tagValue(tag, 3)451 case "A":452 ref.rootAddress = tag[1]453 case "a":454 ref.parentAddress = tag[1]455 case "K":456 ref.rootKind = atoiTag(tag[1])457 case "k":458 ref.parentKind = atoiTag(tag[1])459 case "P":460 ref.rootAuthor = tag[1]461 case "p":462 ref.parentAuthor = tag[1]463 }464 }465 if ref.rootAddress != "" && ref.rootAuthor == "" {466 ref.rootAuthor = addressAuthor(ref.rootAddress)467 }468 if ref.parentAddress != "" && ref.parentAuthor == "" {469 ref.parentAuthor = addressAuthor(ref.parentAddress)470 }471 return ref472}473474func socialCommentFor(comment, post event.Event) bool {475 ref := socialReference(comment)476 if comment.Kind == 1 && post.Kind == 1 {477 return ref.parent == post.ID || ref.root == post.ID478 }479 if post.Kind == 30023 {480 address := socialAddress(post)481 if comment.Kind == 1111 {482 return ref.rootKind == 30023 && (ref.rootAddress == address || ref.root == post.ID)483 }484 // Older long-form clients used kind 1 with an article a tag. Read these485 // comments for compatibility, but new comments always use NIP-22.486 return comment.Kind == 1 && ref.rootAddress == address487 }488 if post.Kind == 1111 {489 return comment.Kind == 1111 && ref.parent == post.ID && ref.parentKind == 1111490 }491 return false492}493func socialItemFrom(row event.Event) socialItem {494 item := socialItem{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Pubkey: row.PubKey, Tags: row.Tags, CreatedAt: row.CreatedAt, Content: row.Content, Title: event.Tag(row, "title"), Summary: event.Tag(row, "summary"), Image: event.Tag(row, "image"), Event: row, Address: socialAddress(row)}495 item.ReadPath = "/social/" + row.ID496 if item.Address != "" {497 item.ReadPath = "/social?address=" + url.QueryEscape(item.Address)498 item.ReadMinutes = maxInt(1, (len(strings.Fields(row.Content))+219)/220)499 }500 ref := socialReference(row)501 item.ParentID, item.ParentAuthor, item.ParentKind, item.ParentAddress = ref.parent, ref.parentAuthor, ref.parentKind, ref.parentAddress502 if ref.root == "" && ref.rootAddress == "" {503 ref.root, ref.rootAuthor, ref.rootKind, ref.rootAddress = row.ID, row.PubKey, row.Kind, item.Address504 }505 item.RootID, item.RootAuthor, item.RootKind, item.RootAddress = ref.root, ref.rootAuthor, ref.rootKind, ref.rootAddress506 return item507}508func maxInt(a, b int) int {509 if a > b {510 return a511 }512 return b513}514func socialFilter(identifier string) (event.Filter, error) {515 if socialHex(identifier) {516 return event.Filter{IDs: []string{identifier}}, nil517 }518 parts := strings.SplitN(identifier, ":", 3)519 if len(parts) != 3 || parts[0] != "30023" || !socialHex(parts[1]) {520 return event.Filter{}, errors.New("invalid: social post address")521 }522 return event.Filter{Kinds: []int{30023}, Authors: []string{parts[1]}, Tags: map[string][]string{"d": {parts[2]}}}, nil523}524func (t *Tenant) socialPost(ctx context.Context, actor, identifier string) (event.Event, error) {525 filter, err := socialFilter(identifier)526 if err != nil {527 return event.Event{}, err528 }529 rows, err := t.socialEvents(ctx, actor, []event.Filter{filter})530 if err != nil {531 return event.Event{}, err532 }533 if len(rows) != 1 || (rows[0].Kind != 1 && rows[0].Kind != 30023 && rows[0].Kind != 1111) || event.Tag(rows[0], "h") != "" {534 return event.Event{}, errors.New("not found: social post")535 }536 if rows[0].Kind == 1111 && socialReference(rows[0]).rootKind != 30023 {537 return event.Event{}, errors.New("not found: social comment")538 }539 return rows[0], nil540}541func (t *Tenant) browseSocialThread(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {542 identifier := q.ID543 if identifier == "" {544 identifier = q.Address545 }546 post, err := t.socialPost(ctx, actor, identifier)547 if err != nil {548 return nil, err549 }550 items, err := t.socialItems(ctx, actor, []event.Event{post})551 if err != nil {552 return nil, err553 }554 item := items[0]555 if post.Kind == 1111 || post.Kind == 1 && item.ParentID != "" {556 rootIdentifier := item.RootID557 if item.RootAddress != "" {558 rootIdentifier = item.RootAddress559 }560 root, rootErr := t.socialPost(ctx, actor, rootIdentifier)561 if post.Kind == 1111 && rootErr != nil {562 return nil, errors.New("not found: article for comment")563 }564 if rootErr == nil {565 item.RootID, item.RootAuthor, item.RootKind, item.RootAddress = root.ID, root.PubKey, root.Kind, socialAddress(root)566 }567 }568 cursor, err := parseCollaborationCursor(q.Cursor)569 if err != nil {570 return nil, err571 }572 activity, err := t.socialEvents(ctx, actor, socialActivityFilters([]event.Event{post}))573 if err != nil {574 return nil, err575 }576 replies := []event.Event{}577 for _, row := range activity {578 if !socialCommentFor(row, post) {579 continue580 }581 if cursor != nil && (row.CreatedAt > cursor.CreatedAt || row.CreatedAt == cursor.CreatedAt && row.ID <= cursor.ID) {582 continue583 }584 replies = append(replies, row)585 }586 next := ""587 if len(replies) > q.Limit {588 next = socialCursor(replies[q.Limit-1])589 replies = replies[:q.Limit]590 }591 comments, err := t.socialItems(ctx, actor, replies)592 if err != nil {593 return nil, err594 }595 return map[string]any{"post": item, "comments": comments, "next_cursor": next}, nil596}