tinyrelayfile
tinyrelay/internal/daemon/social_browse.go/

internal/daemon/social_browse.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net/url"
9 "sort"
10 "strconv"
11 "strings"
12 "time"
13
14 "github.com/FelineStateMachine/tinyrelay/internal/event"
15 "github.com/FelineStateMachine/tinyrelay/internal/storage"
16)
17
18type 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}
27
28type 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}
57
58type 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}
64
65func socialBrowseMethod(method string) bool {
66 return method == "browsesocial" || method == "browsesocialthread"
67}
68
69func (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 = 30
81 }
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}
90
91func 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.ID
106 }
107 return a.CreatedAt > b.CreatedAt
108}
109
110func (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, err
124 }
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, err
136 }
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 break
144 }
145 }
146 if len(rows) > q.Limit {
147 next = socialCursor(rows[q.Limit-1])
148 rows = rows[:q.Limit]
149 break
150 }
151 if !page.More || len(page.Events) == 0 {
152 break
153 }
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, err
161 }
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.ReplyCount
170 }
171 return map[string]any{"items": items, "next_cursor": next, "stats": stats}, nil
172}
173
174func socialFeedRoot(row event.Event) bool {
175 if row.Kind == 30023 {
176 return true
177 }
178 if row.Kind != 1 || event.Tag(row, "h") != "" {
179 return false
180 }
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}
188
189// socialEvents scans only the requested references and applies the same per-event
190// 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.EventCursor
197 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, err
201 }
202 for _, row := range page.Events {
203 if !seen[row.ID] && t.gate.CanSee(ctx, row, session, &filter) {
204 seen[row.ID] = true
205 out = append(out, row)
206 }
207 }
208 if !page.More || len(page.Events) == 0 {
209 break
210 }
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, nil
217}
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 filters
231}
232func (t *Tenant) socialItems(ctx context.Context, actor string, rows []event.Event) ([]socialItem, error) {
233 if len(rows) == 0 {
234 return []socialItem{}, nil
235 }
236 extra, err := t.socialEvents(ctx, actor, socialActivityFilters(rows))
237 if err != nil {
238 return nil, err
239 }
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] = true
246 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, err
252 }
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.Name
263 }
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, nil
271}
272
273func 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] = i
279 if a := socialAddress(row); a != "" {
280 index[a] = i
281 }
282 }
283 type reactionKey struct {
284 index int
285 author, content string
286 }
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.parent
293 if _, ok := index[ref.parentAddress]; ok {
294 target = ref.parentAddress
295 }
296 i, ok := index[target]
297 if !ok {
298 continue
299 }
300 content, image := socialReactionValue(row)
301 key := reactionKey{i, row.PubKey, content + "\x00" + image}
302 if seen[key] {
303 continue
304 }
305 seen[key] = true
306 if counts[i] == nil {
307 counts[i] = map[string]socialReaction{}
308 }
309 variant := content + "\x00" + image
310 r := counts[i][variant]
311 r.Content = content
312 r.Image = image
313 r.Count++
314 r.Reacted = r.Reacted || row.PubKey == actor
315 counts[i][variant] = r
316 } 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].Image
331 }
332 return items[i].Reactions[a].Content < items[i].Reactions[b].Content
333 })
334 }
335 return items
336}
337
338// socialReactionValue applies NIP-30 custom emoji metadata only when the
339// reaction content and emoji tag form a valid, safe pair. Invalid metadata is
340// retained as ordinary reaction text so it cannot turn into an image URL.
341func socialReactionValue(row event.Event) (string, string) {
342 content := row.Content
343 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 continue
356 }
357 if socialEmojiURL(tag[2]) {
358 return content, tag[2]
359 }
360 }
361 return content, ""
362}
363
364func socialEmojiShortcode(value string) bool {
365 if value == "" {
366 return false
367 }
368 for _, char := range value {
369 if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' && char != '_' {
370 return false
371 }
372 }
373 return true
374}
375
376func socialEmojiURL(value string) bool {
377 parsed, err := url.Parse(value)
378 return err == nil && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.Hostname() != "" && parsed.User == nil
379}
380
381type socialRef struct {
382 root, parent, rootAuthor, parentAuthor string
383 rootKind, parentKind int
384 rootAddress, parentAddress string
385}
386
387func 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 [][]string
405 marked := false
406 for _, tag := range row.Tags {
407 if len(tag) < 2 {
408 continue
409 }
410 if tag[0] == "a" {
411 ref.parentAddress = tag[1]
412 ref.rootAddress = tag[1]
413 continue
414 }
415 if tag[0] != "e" {
416 continue
417 }
418 switch tagValue(tag, 3) {
419 case "root":
420 ref.root, ref.rootAuthor = tag[1], tagValue(tag, 4)
421 marked = true
422 case "reply":
423 ref.parent, ref.parentAuthor = tag[1], tagValue(tag, 4)
424 marked = true
425 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.parentAuthor
435 }
436 if ref.parent == "" {
437 ref.parent, ref.parentAuthor = ref.root, ref.rootAuthor
438 }
439 ref.rootKind, ref.parentKind = 1, 1
440 return ref
441 }
442 for _, tag := range row.Tags {
443 if len(tag) < 2 {
444 continue
445 }
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 ref
472}
473
474func 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.ID
478 }
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 these
485 // comments for compatibility, but new comments always use NIP-22.
486 return comment.Kind == 1 && ref.rootAddress == address
487 }
488 if post.Kind == 1111 {
489 return comment.Kind == 1111 && ref.parent == post.ID && ref.parentKind == 1111
490 }
491 return false
492}
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.ID
496 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.parentAddress
502 if ref.root == "" && ref.rootAddress == "" {
503 ref.root, ref.rootAuthor, ref.rootKind, ref.rootAddress = row.ID, row.PubKey, row.Kind, item.Address
504 }
505 item.RootID, item.RootAuthor, item.RootKind, item.RootAddress = ref.root, ref.rootAuthor, ref.rootKind, ref.rootAddress
506 return item
507}
508func maxInt(a, b int) int {
509 if a > b {
510 return a
511 }
512 return b
513}
514func socialFilter(identifier string) (event.Filter, error) {
515 if socialHex(identifier) {
516 return event.Filter{IDs: []string{identifier}}, nil
517 }
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]}}}, nil
523}
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{}, err
528 }
529 rows, err := t.socialEvents(ctx, actor, []event.Filter{filter})
530 if err != nil {
531 return event.Event{}, err
532 }
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], nil
540}
541func (t *Tenant) browseSocialThread(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {
542 identifier := q.ID
543 if identifier == "" {
544 identifier = q.Address
545 }
546 post, err := t.socialPost(ctx, actor, identifier)
547 if err != nil {
548 return nil, err
549 }
550 items, err := t.socialItems(ctx, actor, []event.Event{post})
551 if err != nil {
552 return nil, err
553 }
554 item := items[0]
555 if post.Kind == 1111 || post.Kind == 1 && item.ParentID != "" {
556 rootIdentifier := item.RootID
557 if item.RootAddress != "" {
558 rootIdentifier = item.RootAddress
559 }
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, err
571 }
572 activity, err := t.socialEvents(ctx, actor, socialActivityFilters([]event.Event{post}))
573 if err != nil {
574 return nil, err
575 }
576 replies := []event.Event{}
577 for _, row := range activity {
578 if !socialCommentFor(row, post) {
579 continue
580 }
581 if cursor != nil && (row.CreatedAt > cursor.CreatedAt || row.CreatedAt == cursor.CreatedAt && row.ID <= cursor.ID) {
582 continue
583 }
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, err
594 }
595 return map[string]any{"post": item, "comments": comments, "next_cursor": next}, nil
596}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs