1package records23import (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"1516 "github.com/FelineStateMachine/tinyrelay/internal/event"17 "github.com/FelineStateMachine/tinyrelay/internal/policy"18 "github.com/FelineStateMachine/tinyrelay/internal/storage"19)2021func effectiveViewTrigger(p policy.Policy, name string) string {22 if mode, ok := p.Views[name]; ok && mode != "" {23 return mode24 }25 return defaultViewTrigger(name)26}2728func defaultViewTrigger(name string) string {29 return map[string]string{"profiles": "daily", "relays": "daily", "calendar": "hourly", "moderation": "daily", "articles": "write", "podcasts": "write", "zaps": "hourly", "presence": "live"}[name]30}3132// 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}4445func viewStored(p policy.Policy, name string) bool {46 return name != "presence" && viewAudience(p, name) == "public"47}4849func (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 []View58 for _, name := range viewNames {59 if name == "presence" {60 continue61 }62 if effectiveViewTrigger(p, name) == "off" {63 continue64 }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, err71 }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, err78 }79 out = append(out, View{Name: "presence", Event: e, Rows: maxInt(0, len(e.Tags)-1)})80 }81 return out, nil82}8384// 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{}, err103 }104 return View{Name: name, Event: e, Rows: maxInt(0, len(e.Tags)-1)}, nil105 }106 e, err := s.view(ctx, name, caller, now)107 if err != nil {108 return View{}, err109 }110 rows := len(e.Tags) - 3111 if name == "presence" {112 rows = len(e.Tags) - 1113 }114 return View{Name: name, Event: e, Rows: maxInt(0, rows)}, nil115}116117func containsView(name string) bool {118 for _, n := range viewNames {119 if n == name {120 return true121 }122 }123 return false124}125126func (s *Service) view(ctx context.Context, name string, caller policy.Access, now int64) (event.Event, error) {127 filter := event.Filter{}128 if name == "podcasts" {129 filter.Kinds = []int{54, 10154, 10064}130 }131 rows, err := s.store.Query(ctx, filter, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 0})132 if err != nil {133 return event.Event{}, err134 }135 var tags [][]string136 var content string137 if name == "moderation" {138 tags, content = s.moderationFold(ctx, now)139 } else if name == "zaps" {140 tags, content = s.zapsFold(ctx, rows.Events, now)141 } else if name == "profiles" {142 people, names := s.viewPeople(ctx)143 rows.Events = onlyAuthors(rows.Events, people)144 tags, content = profileFold(rows.Events, s.relayURL, people, names)145 } else if name == "relays" {146 people, _ := s.viewPeople(ctx)147 tags, content = fold(name, onlyAuthors(rows.Events, people), s.relayURL, now)148 } else if name == "podcasts" {149 visible := rows.Events[:0]150 access := caller151 if access.Owner && len(access.PubKeys) == 0 && s.policy().Owner != "" {152 access.PubKeys = []string{s.policy().Owner}153 }154 for _, row := range rows.Events {155 if policy.CanRead(s.policy(), row, access) && (s.eventVisible == nil || s.eventVisible(ctx, row, access)) {156 visible = append(visible, row)157 }158 }159 rows.Events = visible160 tags, content = foldPodcasts(rows.Events)161 } else {162 tags, content = fold(name, rows.Events, s.relayURL, now)163 }164 trigger := s.policy().Views[name]165 if trigger == "" {166 trigger = map[string]string{"profiles": "daily", "relays": "daily", "calendar": "hourly", "moderation": "daily", "articles": "write", "podcasts": "write", "zaps": "hourly"}[name]167 }168 tags = append([][]string{{"-"}, {"d", "bind.ws/view/" + name}, {"trigger", trigger}}, tags...)169 if viewStored(s.policy(), name) {170 return s.signed(ctx, event.KIND_VIEW, tags, content, now)171 }172 return s.signedOnly(ctx, event.KIND_VIEW, tags, content, now)173}174175func onlyAuthors(events []event.Event, allowed map[string]string) []event.Event {176 out := make([]event.Event, 0, len(events))177 for _, e := range events {178 if _, ok := allowed[e.PubKey]; ok {179 out = append(out, e)180 }181 }182 return out183}184185func (s *Service) viewPeople(ctx context.Context) (map[string]string, []string) {186 p := s.policy()187 people := make(map[string]string)188 order := make([]string, 0, 8)189 if p.Owner != "" {190 people[p.Owner] = ""191 order = append(order, p.Owner)192 }193 members, err := s.members(ctx)194 if err != nil {195 return people, order196 }197 for _, member := range members {198 if member.PubKey == "" {199 continue200 }201 if _, exists := people[member.PubKey]; !exists {202 order = append(order, member.PubKey)203 }204 people[member.PubKey] = member.Name205 }206 return people, order207}208209func profileFold(events []event.Event, relayURL string, people map[string]string, order []string) ([][]string, string) {210 latest := make(map[string]event.Event)211 for _, e := range events {212 if e.Kind != event.KIND_PROFILE {213 continue214 }215 if old, ok := latest[e.PubKey]; !ok || e.CreatedAt > old.CreatedAt {216 latest[e.PubKey] = e217 }218 }219 host := relayURL220 if u, err := url.Parse(relayURL); err == nil && u.Host != "" {221 host = u.Host222 }223 host = strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(host, "wss://"), "ws://"), "/")224 if host == "" {225 host = "relay"226 }227 out := make([][]string, 0, len(order))228 for _, pk := range order {229 m := map[string]any{}230 if e, ok := latest[pk]; ok {231 _ = json.Unmarshal([]byte(e.Content), &m)232 }233 nip05 := trimString(m["nip05"], 200)234 if people[pk] != "" {235 nip05 = people[pk] + "@" + host236 }237 out = append(out, []string{"p", pk, trimString(m["name"], 200), trimString(m["picture"], 2000), nip05})238 }239 return out, ""240}241242func (s *Service) zapsFold(ctx context.Context, events []event.Event, now int64) ([][]string, string) {243 allowed := map[string]bool{}244 if owner := s.policy().Owner; owner != "" {245 allowed[owner] = true246 }247 if members, err := s.members(ctx); err == nil {248 for _, member := range members {249 allowed[member.PubKey] = true250 }251 }252 byEvent, byAuthor := map[string]int64{}, map[string]int64{}253 for _, receipt := range events {254 if receipt.Kind != 9735 {255 continue256 }257 var req event.Event258 if json.Unmarshal([]byte(event.Tag(receipt, "description")), &req) != nil || req.Kind != 9734 {259 continue260 }261 msats := bolt11MSats(event.Tag(receipt, "bolt11"))262 if msats <= 0 {263 continue264 }265 if pk := event.Tag(req, "p"); allowed[pk] {266 byAuthor[pk] += msats267 }268 if id := event.Tag(req, "e"); len(id) == 64 {269 var exists int270 if s.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE id=?`, id).Scan(&exists) == nil {271 byEvent[id] += msats272 }273 }274 }275 out := make([][]string, 0, len(byEvent)+len(byAuthor))276 for id, n := range byEvent {277 out = append(out, []string{"e", id, strconv.FormatInt(n, 10)})278 }279 for pk, n := range byAuthor {280 out = append(out, []string{"p", pk, strconv.FormatInt(n, 10)})281 }282 sort.Slice(out, func(i, j int) bool {283 ni, _ := strconv.ParseInt(out[i][2], 10, 64)284 nj, _ := strconv.ParseInt(out[j][2], 10, 64)285 if ni != nj {286 return ni > nj287 }288 return out[i][1] < out[j][1]289 })290 return out, ""291}292293func (s *Service) moderationFold(ctx context.Context, now int64) ([][]string, string) {294 month := time.Unix(now, 0).UTC().Format("2006-01")295 start := time.Unix(now, 0).UTC()296 start = time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)297 // Preserve available counts and the wire format during a partial failure.298 // The community reader leaves each unavailable counter at zero.299 moderation, _ := s.moderationCounts(ctx, start.Unix())300 counts := map[string]any{"month": month, "bans": moderation.Bans, "reports": moderation.Reports, "resolved": moderation.Resolved, "hidden": moderation.Hidden, "deleted": 0, "blocked_addresses": moderation.BlockedAddresses}301 b, _ := json.Marshal(counts)302 return [][]string{{"month", month}}, string(b)303}304305func fold(name string, events []event.Event, relayURL string, now int64) ([][]string, string) {306 profiles := map[string]event.Event{}307 relays := map[string]event.Event{}308 for _, e := range events {309 if e.Kind == event.KIND_PROFILE {310 if _, ok := profiles[e.PubKey]; !ok {311 profiles[e.PubKey] = e312 }313 }314 if e.Kind == 10002 {315 if _, ok := relays[e.PubKey]; !ok {316 relays[e.PubKey] = e317 }318 }319 }320 switch name {321 case "profiles":322 out := [][]string{}323 for pk, e := range profiles {324 if e.Kind != event.KIND_PROFILE {325 continue326 }327 var m map[string]any328 _ = json.Unmarshal([]byte(e.Content), &m)329 out = append(out, []string{"p", pk, trimString(m["name"], 200), trimString(m["picture"], 200), trimString(m["nip05"], 200)})330 }331 sort.Slice(out, func(i, j int) bool { return out[i][1] < out[j][1] })332 return out, ""333 case "relays":334 counts := map[string]int{}335 for _, e := range relays {336 seen := map[string]bool{}337 for _, t := range e.Tags {338 if len(t) < 2 || t[0] != "r" {339 continue340 }341 x, err := url.Parse(strings.TrimSpace(t[1]))342 if err != nil || (x.Scheme != "ws" && x.Scheme != "wss") || x.Host == "" {343 continue344 }345 u := strings.ToLower(x.Scheme + "://" + x.Host + strings.TrimRight(x.EscapedPath(), "/"))346 if x.RawQuery != "" {347 u += "?" + x.RawQuery348 }349 if seen[u] {350 continue351 }352 seen[u] = true353 counts[u]++354 }355 }356 keys := make([]string, 0, len(counts))357 for u := range counts {358 keys = append(keys, u)359 }360 sort.Slice(keys, func(i, j int) bool {361 if counts[keys[i]] != counts[keys[j]] {362 return counts[keys[i]] > counts[keys[j]]363 }364 return keys[i] < keys[j]365 })366 out := [][]string{}367 for _, u := range keys[:minInt(len(keys), 100)] {368 out = append(out, []string{"r", u, strconv.Itoa(counts[u])})369 }370 return out, ""371 case "calendar":372 type cal struct {373 e event.Event374 at int64375 }376 var upcoming []cal377 for _, e := range events {378 if e.Kind != 31922 && e.Kind != 31923 {379 continue380 }381 at := calendarStart(e)382 if at < now-86400 || at > now+30*86400 {383 continue384 }385 upcoming = append(upcoming, cal{e, at})386 }387 sort.Slice(upcoming, func(i, j int) bool { return upcoming[i].at < upcoming[j].at })388 rsvp := map[string]map[string]bool{}389 for _, e := range events {390 if e.Kind != 31925 || event.Tag(e, "status") != "accepted" {391 continue392 }393 for _, a := range event.TagValues(e, "a") {394 if rsvp[a] == nil {395 rsvp[a] = map[string]bool{}396 }397 rsvp[a][e.PubKey] = true398 }399 }400 out := [][]string{}401 for _, x := range upcoming[:minInt(len(upcoming), 50)] {402 a := strconv.Itoa(x.e.Kind) + ":" + x.e.PubKey + ":" + event.Tag(x.e, "d")403 row := []string{"a", a, strconv.FormatInt(x.at, 10), event.Tag(x.e, "title")}404 if n := len(rsvp[a]); n > 0 {405 row = append(row, strconv.Itoa(n))406 }407 out = append(out, row)408 }409 return out, ""410 case "moderation":411 counts := map[string]int{"reports": 0, "deleted": 0}412 for _, e := range events {413 if e.Kind == event.KIND_REPORT {414 counts["reports"]++415 }416 if e.Kind == event.KIND_DELETION {417 counts["deleted"]++418 }419 }420 b, _ := json.Marshal(map[string]any{"month": time.Unix(now, 0).UTC().Format("2006-01"), "reports": counts["reports"], "deleted": counts["deleted"]})421 return [][]string{{"month", time.Unix(now, 0).UTC().Format("2006-01")}}, string(b)422 case "articles":423 articles := make([]event.Event, 0)424 for _, e := range events {425 if e.Kind == 30023 {426 articles = append(articles, e)427 }428 }429 sort.SliceStable(articles, func(i, j int) bool { return articlePublished(articles[i]) > articlePublished(articles[j]) })430 out := [][]string{}431 for _, e := range articles[:minInt(len(articles), 100)] {432 published := event.Tag(e, "published_at")433 if published == "" {434 published = strconv.FormatInt(e.CreatedAt, 10)435 }436 out = append(out, []string{"a", strconv.Itoa(e.Kind) + ":" + e.PubKey + ":" + event.Tag(e, "d"), event.Tag(e, "title"), published})437 }438 return out, ""439 case "zaps":440 byEvent, byAuthor := map[string]int64{}, map[string]int64{}441 for _, receipt := range events {442 if receipt.Kind != 9735 {443 continue444 }445 var req event.Event446 if json.Unmarshal([]byte(event.Tag(receipt, "description")), &req) != nil || req.Kind != 9734 {447 continue448 }449 msats := bolt11MSats(event.Tag(receipt, "bolt11"))450 if msats <= 0 {451 continue452 }453 if target := event.Tag(req, "p"); target != "" {454 byAuthor[target] += msats455 }456 if target := event.Tag(req, "e"); len(target) == 64 {457 byEvent[target] += msats458 }459 }460 out := make([][]string, 0, len(byEvent)+len(byAuthor))461 for id, n := range byEvent {462 out = append(out, []string{"e", id, strconv.FormatInt(n, 10)})463 }464 for pk, n := range byAuthor {465 out = append(out, []string{"p", pk, strconv.FormatInt(n, 10)})466 }467 sort.Slice(out, func(i, j int) bool {468 ni, _ := strconv.ParseInt(out[i][2], 10, 64)469 nj, _ := strconv.ParseInt(out[j][2], 10, 64)470 if ni != nj {471 return ni > nj472 }473 return out[i][1] < out[j][1]474 })475 return out, ""476 case "presence":477 return nil, ""478 default:479 return nil, ""480 }481}482483// bolt11MSats parses the amount prefix of a BOLT11 invoice.484func bolt11MSats(invoice string) int64 {485 if len(invoice) < 6 || !strings.HasPrefix(strings.ToLower(invoice), "lnbc") {486 return 0487 }488 s := strings.ToLower(invoice[4:])489 i := 0490 for i < len(s) && s[i] >= '0' && s[i] <= '9' {491 i++492 }493 if i == 0 {494 return 0495 }496 amount := s[:i]497 suffix := ""498 if i < len(s) {499 suffix = s[i : i+1]500 }501 var factor float64502 switch suffix {503 case "m":504 factor = 1e8505 case "u":506 factor = 1e5507 case "n":508 factor = 1e2509 case "p":510 factor = 0.1511 case "":512 factor = 1e11513 default:514 return 0515 }516 var value float64517 if _, err := fmt.Sscanf(amount, "%f", &value); err != nil {518 return 0519 }520 value *= factor521 if value <= 0 || value > math.MaxInt64 {522 return 0523 }524 return int64(value + 0.5)525}526527// MarkView marks a write-triggered view dirty.528func (s *Service) MarkView(ctx context.Context, name string, now int64) error {529 p := s.policy()530 if name == "presence" || effectiveViewTrigger(p, name) != "write" || !viewStored(p, name) {531 return nil532 }533 var dirty int64534 if err := s.store.GetSetting(ctx, "records.view."+name+".dirty", &dirty); err != nil && !errors.Is(err, sql.ErrNoRows) {535 return err536 }537 if dirty == 0 {538 return s.store.PutSetting(ctx, "records.view."+name+".dirty", now)539 }540 return nil541}542543// NextDue returns the earliest scheduled view or pending write deadline.544func (s *Service) NextDue(ctx context.Context, now int64) (int64, error) {545 p := s.policy()546 next := int64(0)547 periods := map[string]int64{"profiles": 86400, "relays": 86400, "calendar": 3600, "moderation": 86400, "articles": 86400, "podcasts": 86400, "zaps": 3600}548 for name, period := range periods {549 mode := effectiveViewTrigger(p, name)550 if mode == "off" || !viewStored(p, name) {551 continue552 }553 if mode == "write" {554 period = 86400555 }556 if mode == "hourly" {557 period = 3600558 }559 if mode == "daily" {560 period = 86400561 }562 if mode == "write" {563 var dirty int64564 if err := s.store.GetSetting(ctx, "records.view."+name+".dirty", &dirty); err != nil && !errors.Is(err, sql.ErrNoRows) {565 return 0, err566 }567 if dirty != 0 {568 due := dirty + 10569 if due <= now {570 return now, nil571 }572 if next == 0 || due < next {573 next = due574 }575 continue576 }577 }578 var last int64579 if err := s.store.GetSetting(ctx, "records.view."+name+".at", &last); err != nil && !errors.Is(err, sql.ErrNoRows) {580 return 0, err581 }582 due := last + period583 if last == 0 || due <= now {584 return now, nil585 }586 if next == 0 || due < next {587 next = due588 }589 }590 return next, nil591}592593func (s *Service) viewFingerprint(ctx context.Context, name string, now int64) (string, error) {594 var n, max sql.NullInt64595 var q string596 switch name {597 case "calendar":598 q = `SELECT count(*),max(created_at) FROM events WHERE kind IN (31922,31923,31925)`599 case "zaps":600 q = `SELECT count(*),max(created_at) FROM events WHERE kind=9735`601 default:602 return "", nil603 }604 if err := s.store.DB().QueryRowContext(ctx, q).Scan(&n, &max); err != nil {605 return "", err606 }607 day := int64(0)608 if name == "calendar" {609 day = now / 86400610 }611 return fmt.Sprintf("%d:%d:%d", n.Int64, max.Int64, day), nil612}613614func (s *Service) recordViewRun(ctx context.Context, name string, at int64, rows int) error {615 if _, err := s.store.DB().ExecContext(ctx, `INSERT INTO records_view_runs(name,at,rows) VALUES(?,?,?)`, name, at, rows); err != nil {616 return err617 }618 _, 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)619 return err620}621622// ViewRuns returns bounded view run history.623func (s *Service) ViewRuns(ctx context.Context, name string) ([]ViewRun, error) {624 rows, err := s.store.DB().QueryContext(ctx, `SELECT at,rows FROM records_view_runs WHERE name=? ORDER BY at,rowid`, name)625 if err != nil {626 return nil, err627 }628 defer rows.Close()629 out := make([]ViewRun, 0, 60)630 for rows.Next() {631 var r ViewRun632 if err := rows.Scan(&r.At, &r.Rows); err != nil {633 return nil, err634 }635 out = append(out, r)636 }637 return out, rows.Err()638}639640func (s *Service) ViewRowsSince(ctx context.Context, name string, since int64) (int64, error) {641 var total sql.NullInt64642 err := s.store.DB().QueryRowContext(ctx, `SELECT sum(rows) FROM records_view_runs WHERE name=? AND at>=?`, name, since).Scan(&total)643 if err != nil {644 return 0, err645 }646 return total.Int64, nil647}648649func (s *Service) ViewSummaries(ctx context.Context) ([]map[string]any, error) {650 p := s.policy()651 out := make([]map[string]any, 0, len(viewNames))652 for _, name := range viewNames {653 trigger := effectiveViewTrigger(p, name)654 runs, err := s.ViewRuns(ctx, name)655 if err != nil {656 return nil, err657 }658 var last any659 if len(runs) > 0 {660 last = runs[len(runs)-1]661 }662 choices := []string{"off", "write", "hourly", "daily"}663 if name == "presence" {664 choices = []string{"off"}665 }666 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", "podcasts": "the newest hundred NIP-F4 episodes and their shows", "zaps": "zap totals for the top notes and authors here", "presence": "who is connected now and who wrote in the last 15 minutes"}[name]667 out = append(out, map[string]any{668 "name": name, "about": about, "trigger": trigger, "default": defaultViewTrigger(name), "choices": choices,669 "audience": viewAudience(p, name), "on": trigger != "off", "stored": viewStored(p, name),670 "last": last, "path": "/view/" + name,671 })672 }673 return out, nil674}675676func (s *Service) ViewNames() []string { return append([]string(nil), viewNames...) }