tinyrelayfile
tinyrelay/internal/records/views.go/

internal/records/views.go raw
1package records
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "math"
10 "net/url"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/FelineStateMachine/tinyrelay/internal/event"
17 "github.com/FelineStateMachine/tinyrelay/internal/policy"
18 "github.com/FelineStateMachine/tinyrelay/internal/storage"
19)
20
21func effectiveViewTrigger(p policy.Policy, name string) string {
22 if mode, ok := p.Views[name]; ok && mode != "" {
23 return mode
24 }
25 return defaultViewTrigger(name)
26}
27
28func defaultViewTrigger(name string) string {
29 return map[string]string{"profiles": "daily", "relays": "daily", "calendar": "hourly", "moderation": "daily", "articles": "write", "zaps": "hourly", "presence": "live"}[name]
30}
31
32// viewAudience applies directory and relay read policy to a view.
33func viewAudience(p policy.Policy, name string) string {
34 if name == "profiles" || name == "relays" {
35 if !p.DirectoryPublic {
36 return "members"
37 }
38 }
39 if p.Reads == "members" {
40 return "members"
41 }
42 return "public"
43}
44
45func viewStored(p policy.Policy, name string) bool {
46 return name != "presence" && viewAudience(p, name) == "public"
47}
48
49func (s *Service) Views(ctx context.Context, caller policy.Access, now int64) ([]View, error) {
50 p := s.policy()
51 if p.Reads == "members" && !caller.Member && !caller.Owner {
52 return nil, errors.New("restricted: views")
53 }
54 if p.Reads == "auth" && len(caller.PubKeys) == 0 && !caller.Owner {
55 return nil, errors.New("auth-required: views")
56 }
57 var out []View
58 for _, name := range viewNames {
59 if name == "presence" {
60 continue
61 }
62 if effectiveViewTrigger(p, name) == "off" {
63 continue
64 }
65 if viewAudience(p, name) == "members" && !caller.Member && !caller.Owner {
66 return nil, errors.New("auth-required: view is for members")
67 }
68 v, err := s.View(ctx, name, caller, now)
69 if err != nil {
70 return nil, err
71 }
72 out = append(out, v)
73 }
74 if p.Views["presence"] != "off" {
75 e, err := s.Presence(ctx, caller, now)
76 if err != nil {
77 return nil, err
78 }
79 out = append(out, View{Name: "presence", Event: e, Rows: maxInt(0, len(e.Tags)-1)})
80 }
81 return out, nil
82}
83
84// View folds and signs one named view after applying audience and read policy.
85func (s *Service) View(ctx context.Context, name string, caller policy.Access, now int64) (View, error) {
86 p := s.policy()
87 if !containsView(name) || effectiveViewTrigger(p, name) == "off" {
88 return View{}, errors.New("not found: view")
89 }
90 if p.Reads == "members" && !caller.Member && !caller.Owner {
91 return View{}, errors.New("restricted: views")
92 }
93 if p.Reads == "auth" && len(caller.PubKeys) == 0 && !caller.Owner {
94 return View{}, errors.New("auth-required: views")
95 }
96 if viewAudience(p, name) == "members" && !caller.Member && !caller.Owner {
97 return View{}, errors.New("auth-required: view is for members")
98 }
99 if name == "presence" {
100 e, err := s.Presence(ctx, caller, now)
101 if err != nil {
102 return View{}, err
103 }
104 return View{Name: name, Event: e, Rows: maxInt(0, len(e.Tags)-1)}, nil
105 }
106 e, err := s.view(ctx, name, caller, now)
107 if err != nil {
108 return View{}, err
109 }
110 rows := len(e.Tags) - 3
111 if name == "presence" {
112 rows = len(e.Tags) - 1
113 }
114 return View{Name: name, Event: e, Rows: maxInt(0, rows)}, nil
115}
116
117func containsView(name string) bool {
118 for _, n := range viewNames {
119 if n == name {
120 return true
121 }
122 }
123 return false
124}
125
126func (s *Service) view(ctx context.Context, name string, caller policy.Access, now int64) (event.Event, error) {
127 rows, err := s.store.Query(ctx, event.Filter{}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 0})
128 if err != nil {
129 return event.Event{}, err
130 }
131 var tags [][]string
132 var content string
133 if name == "moderation" {
134 tags, content = s.moderationFold(ctx, now)
135 } else if name == "zaps" {
136 tags, content = s.zapsFold(ctx, rows.Events, now)
137 } else if name == "profiles" {
138 people, names := s.viewPeople(ctx)
139 rows.Events = onlyAuthors(rows.Events, people)
140 tags, content = profileFold(rows.Events, s.relayURL, people, names)
141 } else if name == "relays" {
142 people, _ := s.viewPeople(ctx)
143 tags, content = fold(name, onlyAuthors(rows.Events, people), s.relayURL, now)
144 } else {
145 tags, content = fold(name, rows.Events, s.relayURL, now)
146 }
147 trigger := s.policy().Views[name]
148 if trigger == "" {
149 trigger = map[string]string{"profiles": "daily", "relays": "daily", "calendar": "hourly", "moderation": "daily", "articles": "write", "zaps": "hourly"}[name]
150 }
151 tags = append([][]string{{"-"}, {"d", "bind.ws/view/" + name}, {"trigger", trigger}}, tags...)
152 if viewStored(s.policy(), name) {
153 return s.signed(ctx, event.KIND_VIEW, tags, content, now)
154 }
155 return s.signedOnly(ctx, event.KIND_VIEW, tags, content, now)
156}
157
158func onlyAuthors(events []event.Event, allowed map[string]string) []event.Event {
159 out := make([]event.Event, 0, len(events))
160 for _, e := range events {
161 if _, ok := allowed[e.PubKey]; ok {
162 out = append(out, e)
163 }
164 }
165 return out
166}
167
168func (s *Service) viewPeople(ctx context.Context) (map[string]string, []string) {
169 p := s.policy()
170 people := make(map[string]string)
171 order := make([]string, 0, 8)
172 if p.Owner != "" {
173 people[p.Owner] = ""
174 order = append(order, p.Owner)
175 }
176 members, err := s.members(ctx)
177 if err != nil {
178 return people, order
179 }
180 for _, member := range members {
181 if member.PubKey == "" {
182 continue
183 }
184 if _, exists := people[member.PubKey]; !exists {
185 order = append(order, member.PubKey)
186 }
187 people[member.PubKey] = member.Name
188 }
189 return people, order
190}
191
192func profileFold(events []event.Event, relayURL string, people map[string]string, order []string) ([][]string, string) {
193 latest := make(map[string]event.Event)
194 for _, e := range events {
195 if e.Kind != event.KIND_PROFILE {
196 continue
197 }
198 if old, ok := latest[e.PubKey]; !ok || e.CreatedAt > old.CreatedAt {
199 latest[e.PubKey] = e
200 }
201 }
202 host := relayURL
203 if u, err := url.Parse(relayURL); err == nil && u.Host != "" {
204 host = u.Host
205 }
206 host = strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(host, "wss://"), "ws://"), "/")
207 if host == "" {
208 host = "relay"
209 }
210 out := make([][]string, 0, len(order))
211 for _, pk := range order {
212 m := map[string]any{}
213 if e, ok := latest[pk]; ok {
214 _ = json.Unmarshal([]byte(e.Content), &m)
215 }
216 nip05 := trimString(m["nip05"], 200)
217 if people[pk] != "" {
218 nip05 = people[pk] + "@" + host
219 }
220 out = append(out, []string{"p", pk, trimString(m["name"], 200), trimString(m["picture"], 2000), nip05})
221 }
222 return out, ""
223}
224
225func (s *Service) zapsFold(ctx context.Context, events []event.Event, now int64) ([][]string, string) {
226 allowed := map[string]bool{}
227 if owner := s.policy().Owner; owner != "" {
228 allowed[owner] = true
229 }
230 if members, err := s.members(ctx); err == nil {
231 for _, member := range members {
232 allowed[member.PubKey] = true
233 }
234 }
235 byEvent, byAuthor := map[string]int64{}, map[string]int64{}
236 for _, receipt := range events {
237 if receipt.Kind != 9735 {
238 continue
239 }
240 var req event.Event
241 if json.Unmarshal([]byte(event.Tag(receipt, "description")), &req) != nil || req.Kind != 9734 {
242 continue
243 }
244 msats := bolt11MSats(event.Tag(receipt, "bolt11"))
245 if msats <= 0 {
246 continue
247 }
248 if pk := event.Tag(req, "p"); allowed[pk] {
249 byAuthor[pk] += msats
250 }
251 if id := event.Tag(req, "e"); len(id) == 64 {
252 var exists int
253 if s.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE id=?`, id).Scan(&exists) == nil {
254 byEvent[id] += msats
255 }
256 }
257 }
258 out := make([][]string, 0, len(byEvent)+len(byAuthor))
259 for id, n := range byEvent {
260 out = append(out, []string{"e", id, strconv.FormatInt(n, 10)})
261 }
262 for pk, n := range byAuthor {
263 out = append(out, []string{"p", pk, strconv.FormatInt(n, 10)})
264 }
265 sort.Slice(out, func(i, j int) bool {
266 ni, _ := strconv.ParseInt(out[i][2], 10, 64)
267 nj, _ := strconv.ParseInt(out[j][2], 10, 64)
268 if ni != nj {
269 return ni > nj
270 }
271 return out[i][1] < out[j][1]
272 })
273 return out, ""
274}
275
276func (s *Service) moderationFold(ctx context.Context, now int64) ([][]string, string) {
277 month := time.Unix(now, 0).UTC().Format("2006-01")
278 start := time.Unix(now, 0).UTC()
279 start = time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
280 // Preserve available counts and the wire format during a partial failure.
281 // The community reader leaves each unavailable counter at zero.
282 moderation, _ := s.moderationCounts(ctx, start.Unix())
283 counts := map[string]any{"month": month, "bans": moderation.Bans, "reports": moderation.Reports, "resolved": moderation.Resolved, "hidden": moderation.Hidden, "deleted": 0, "blocked_addresses": moderation.BlockedAddresses}
284 b, _ := json.Marshal(counts)
285 return [][]string{{"month", month}}, string(b)
286}
287
288func fold(name string, events []event.Event, relayURL string, now int64) ([][]string, string) {
289 profiles := map[string]event.Event{}
290 relays := map[string]event.Event{}
291 for _, e := range events {
292 if e.Kind == event.KIND_PROFILE {
293 if _, ok := profiles[e.PubKey]; !ok {
294 profiles[e.PubKey] = e
295 }
296 }
297 if e.Kind == 10002 {
298 if _, ok := relays[e.PubKey]; !ok {
299 relays[e.PubKey] = e
300 }
301 }
302 }
303 switch name {
304 case "profiles":
305 out := [][]string{}
306 for pk, e := range profiles {
307 if e.Kind != event.KIND_PROFILE {
308 continue
309 }
310 var m map[string]any
311 _ = json.Unmarshal([]byte(e.Content), &m)
312 out = append(out, []string{"p", pk, trimString(m["name"], 200), trimString(m["picture"], 200), trimString(m["nip05"], 200)})
313 }
314 sort.Slice(out, func(i, j int) bool { return out[i][1] < out[j][1] })
315 return out, ""
316 case "relays":
317 counts := map[string]int{}
318 for _, e := range relays {
319 seen := map[string]bool{}
320 for _, t := range e.Tags {
321 if len(t) < 2 || t[0] != "r" {
322 continue
323 }
324 x, err := url.Parse(strings.TrimSpace(t[1]))
325 if err != nil || (x.Scheme != "ws" && x.Scheme != "wss") || x.Host == "" {
326 continue
327 }
328 u := strings.ToLower(x.Scheme + "://" + x.Host + strings.TrimRight(x.EscapedPath(), "/"))
329 if x.RawQuery != "" {
330 u += "?" + x.RawQuery
331 }
332 if seen[u] {
333 continue
334 }
335 seen[u] = true
336 counts[u]++
337 }
338 }
339 keys := make([]string, 0, len(counts))
340 for u := range counts {
341 keys = append(keys, u)
342 }
343 sort.Slice(keys, func(i, j int) bool {
344 if counts[keys[i]] != counts[keys[j]] {
345 return counts[keys[i]] > counts[keys[j]]
346 }
347 return keys[i] < keys[j]
348 })
349 out := [][]string{}
350 for _, u := range keys[:minInt(len(keys), 100)] {
351 out = append(out, []string{"r", u, strconv.Itoa(counts[u])})
352 }
353 return out, ""
354 case "calendar":
355 type cal struct {
356 e event.Event
357 at int64
358 }
359 var upcoming []cal
360 for _, e := range events {
361 if e.Kind != 31922 && e.Kind != 31923 {
362 continue
363 }
364 at := calendarStart(e)
365 if at < now-86400 || at > now+30*86400 {
366 continue
367 }
368 upcoming = append(upcoming, cal{e, at})
369 }
370 sort.Slice(upcoming, func(i, j int) bool { return upcoming[i].at < upcoming[j].at })
371 rsvp := map[string]map[string]bool{}
372 for _, e := range events {
373 if e.Kind != 31925 || event.Tag(e, "status") != "accepted" {
374 continue
375 }
376 for _, a := range event.TagValues(e, "a") {
377 if rsvp[a] == nil {
378 rsvp[a] = map[string]bool{}
379 }
380 rsvp[a][e.PubKey] = true
381 }
382 }
383 out := [][]string{}
384 for _, x := range upcoming[:minInt(len(upcoming), 50)] {
385 a := strconv.Itoa(x.e.Kind) + ":" + x.e.PubKey + ":" + event.Tag(x.e, "d")
386 row := []string{"a", a, strconv.FormatInt(x.at, 10), event.Tag(x.e, "title")}
387 if n := len(rsvp[a]); n > 0 {
388 row = append(row, strconv.Itoa(n))
389 }
390 out = append(out, row)
391 }
392 return out, ""
393 case "moderation":
394 counts := map[string]int{"reports": 0, "deleted": 0}
395 for _, e := range events {
396 if e.Kind == event.KIND_REPORT {
397 counts["reports"]++
398 }
399 if e.Kind == event.KIND_DELETION {
400 counts["deleted"]++
401 }
402 }
403 b, _ := json.Marshal(map[string]any{"month": time.Unix(now, 0).UTC().Format("2006-01"), "reports": counts["reports"], "deleted": counts["deleted"]})
404 return [][]string{{"month", time.Unix(now, 0).UTC().Format("2006-01")}}, string(b)
405 case "articles":
406 articles := make([]event.Event, 0)
407 for _, e := range events {
408 if e.Kind == 30023 {
409 articles = append(articles, e)
410 }
411 }
412 sort.SliceStable(articles, func(i, j int) bool { return articlePublished(articles[i]) > articlePublished(articles[j]) })
413 out := [][]string{}
414 for _, e := range articles[:minInt(len(articles), 100)] {
415 published := event.Tag(e, "published_at")
416 if published == "" {
417 published = strconv.FormatInt(e.CreatedAt, 10)
418 }
419 out = append(out, []string{"a", strconv.Itoa(e.Kind) + ":" + e.PubKey + ":" + event.Tag(e, "d"), event.Tag(e, "title"), published})
420 }
421 return out, ""
422 case "zaps":
423 byEvent, byAuthor := map[string]int64{}, map[string]int64{}
424 for _, receipt := range events {
425 if receipt.Kind != 9735 {
426 continue
427 }
428 var req event.Event
429 if json.Unmarshal([]byte(event.Tag(receipt, "description")), &req) != nil || req.Kind != 9734 {
430 continue
431 }
432 msats := bolt11MSats(event.Tag(receipt, "bolt11"))
433 if msats <= 0 {
434 continue
435 }
436 if target := event.Tag(req, "p"); target != "" {
437 byAuthor[target] += msats
438 }
439 if target := event.Tag(req, "e"); len(target) == 64 {
440 byEvent[target] += msats
441 }
442 }
443 out := make([][]string, 0, len(byEvent)+len(byAuthor))
444 for id, n := range byEvent {
445 out = append(out, []string{"e", id, strconv.FormatInt(n, 10)})
446 }
447 for pk, n := range byAuthor {
448 out = append(out, []string{"p", pk, strconv.FormatInt(n, 10)})
449 }
450 sort.Slice(out, func(i, j int) bool {
451 ni, _ := strconv.ParseInt(out[i][2], 10, 64)
452 nj, _ := strconv.ParseInt(out[j][2], 10, 64)
453 if ni != nj {
454 return ni > nj
455 }
456 return out[i][1] < out[j][1]
457 })
458 return out, ""
459 case "presence":
460 return nil, ""
461 default:
462 return nil, ""
463 }
464}
465
466// bolt11MSats parses the amount prefix of a BOLT11 invoice.
467func bolt11MSats(invoice string) int64 {
468 if len(invoice) < 6 || !strings.HasPrefix(strings.ToLower(invoice), "lnbc") {
469 return 0
470 }
471 s := strings.ToLower(invoice[4:])
472 i := 0
473 for i < len(s) && s[i] >= '0' && s[i] <= '9' {
474 i++
475 }
476 if i == 0 {
477 return 0
478 }
479 amount := s[:i]
480 suffix := ""
481 if i < len(s) {
482 suffix = s[i : i+1]
483 }
484 var factor float64
485 switch suffix {
486 case "m":
487 factor = 1e8
488 case "u":
489 factor = 1e5
490 case "n":
491 factor = 1e2
492 case "p":
493 factor = 0.1
494 case "":
495 factor = 1e11
496 default:
497 return 0
498 }
499 var value float64
500 if _, err := fmt.Sscanf(amount, "%f", &value); err != nil {
501 return 0
502 }
503 value *= factor
504 if value <= 0 || value > math.MaxInt64 {
505 return 0
506 }
507 return int64(value + 0.5)
508}
509
510// MarkView marks a write-triggered view dirty.
511func (s *Service) MarkView(ctx context.Context, name string, now int64) error {
512 p := s.policy()
513 if name == "presence" || effectiveViewTrigger(p, name) != "write" || !viewStored(p, name) {
514 return nil
515 }
516 var dirty int64
517 if err := s.store.GetSetting(ctx, "records.view."+name+".dirty", &dirty); err != nil && !errors.Is(err, sql.ErrNoRows) {
518 return err
519 }
520 if dirty == 0 {
521 return s.store.PutSetting(ctx, "records.view."+name+".dirty", now)
522 }
523 return nil
524}
525
526// NextDue returns the earliest scheduled view or pending write deadline.
527func (s *Service) NextDue(ctx context.Context, now int64) (int64, error) {
528 p := s.policy()
529 next := int64(0)
530 periods := map[string]int64{"profiles": 86400, "relays": 86400, "calendar": 3600, "moderation": 86400, "articles": 86400, "zaps": 3600}
531 for name, period := range periods {
532 mode := effectiveViewTrigger(p, name)
533 if mode == "off" || !viewStored(p, name) {
534 continue
535 }
536 if mode == "write" {
537 period = 86400
538 }
539 if mode == "hourly" {
540 period = 3600
541 }
542 if mode == "daily" {
543 period = 86400
544 }
545 if mode == "write" {
546 var dirty int64
547 if err := s.store.GetSetting(ctx, "records.view."+name+".dirty", &dirty); err != nil && !errors.Is(err, sql.ErrNoRows) {
548 return 0, err
549 }
550 if dirty != 0 {
551 due := dirty + 10
552 if due <= now {
553 return now, nil
554 }
555 if next == 0 || due < next {
556 next = due
557 }
558 continue
559 }
560 }
561 var last int64
562 if err := s.store.GetSetting(ctx, "records.view."+name+".at", &last); err != nil && !errors.Is(err, sql.ErrNoRows) {
563 return 0, err
564 }
565 due := last + period
566 if last == 0 || due <= now {
567 return now, nil
568 }
569 if next == 0 || due < next {
570 next = due
571 }
572 }
573 return next, nil
574}
575
576func (s *Service) viewFingerprint(ctx context.Context, name string, now int64) (string, error) {
577 var n, max sql.NullInt64
578 var q string
579 switch name {
580 case "calendar":
581 q = `SELECT count(*),max(created_at) FROM events WHERE kind IN (31922,31923,31925)`
582 case "zaps":
583 q = `SELECT count(*),max(created_at) FROM events WHERE kind=9735`
584 default:
585 return "", nil
586 }
587 if err := s.store.DB().QueryRowContext(ctx, q).Scan(&n, &max); err != nil {
588 return "", err
589 }
590 day := int64(0)
591 if name == "calendar" {
592 day = now / 86400
593 }
594 return fmt.Sprintf("%d:%d:%d", n.Int64, max.Int64, day), nil
595}
596
597func (s *Service) recordViewRun(ctx context.Context, name string, at int64, rows int) error {
598 if _, err := s.store.DB().ExecContext(ctx, `INSERT INTO records_view_runs(name,at,rows) VALUES(?,?,?)`, name, at, rows); err != nil {
599 return err
600 }
601 _, err := s.store.DB().ExecContext(ctx, `DELETE FROM records_view_runs WHERE name=? AND rowid NOT IN (SELECT rowid FROM records_view_runs WHERE name=? ORDER BY at DESC,rowid DESC LIMIT 60)`, name, name)
602 return err
603}
604
605// ViewRuns returns bounded view run history.
606func (s *Service) ViewRuns(ctx context.Context, name string) ([]ViewRun, error) {
607 rows, err := s.store.DB().QueryContext(ctx, `SELECT at,rows FROM records_view_runs WHERE name=? ORDER BY at,rowid`, name)
608 if err != nil {
609 return nil, err
610 }
611 defer rows.Close()
612 out := make([]ViewRun, 0, 60)
613 for rows.Next() {
614 var r ViewRun
615 if err := rows.Scan(&r.At, &r.Rows); err != nil {
616 return nil, err
617 }
618 out = append(out, r)
619 }
620 return out, rows.Err()
621}
622
623func (s *Service) ViewRowsSince(ctx context.Context, name string, since int64) (int64, error) {
624 var total sql.NullInt64
625 err := s.store.DB().QueryRowContext(ctx, `SELECT sum(rows) FROM records_view_runs WHERE name=? AND at>=?`, name, since).Scan(&total)
626 if err != nil {
627 return 0, err
628 }
629 return total.Int64, nil
630}
631
632func (s *Service) ViewSummaries(ctx context.Context) ([]map[string]any, error) {
633 p := s.policy()
634 out := make([]map[string]any, 0, len(viewNames))
635 for _, name := range viewNames {
636 trigger := effectiveViewTrigger(p, name)
637 runs, err := s.ViewRuns(ctx, name)
638 if err != nil {
639 return nil, err
640 }
641 var last any
642 if len(runs) > 0 {
643 last = runs[len(runs)-1]
644 }
645 choices := []string{"off", "write", "hourly", "daily"}
646 if name == "presence" {
647 choices = []string{"off"}
648 }
649 about := map[string]string{"profiles": "every member's newest profile in one record", "relays": "where the members are: the union of their relay lists", "calendar": "what is on: calendar events starting in the next 30 days, with RSVPs counted", "moderation": "this month's moderation counts, no ids", "articles": "the newest hundred articles, by address", "zaps": "zap totals for the top notes and authors here", "presence": "who is connected now and who wrote in the last 15 minutes"}[name]
650 out = append(out, map[string]any{
651 "name": name, "about": about, "trigger": trigger, "default": defaultViewTrigger(name), "choices": choices,
652 "audience": viewAudience(p, name), "on": trigger != "off", "stored": viewStored(p, name),
653 "last": last, "path": "/view/" + name,
654 })
655 }
656 return out, nil
657}
658
659func (s *Service) ViewNames() []string { return append([]string(nil), viewNames...) }

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs