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", "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 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{}, err130 }131 var tags [][]string132 var content string133 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}157158func 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 out166}167168func (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, order179 }180 for _, member := range members {181 if member.PubKey == "" {182 continue183 }184 if _, exists := people[member.PubKey]; !exists {185 order = append(order, member.PubKey)186 }187 people[member.PubKey] = member.Name188 }189 return people, order190}191192func 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 continue197 }198 if old, ok := latest[e.PubKey]; !ok || e.CreatedAt > old.CreatedAt {199 latest[e.PubKey] = e200 }201 }202 host := relayURL203 if u, err := url.Parse(relayURL); err == nil && u.Host != "" {204 host = u.Host205 }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] + "@" + host219 }220 out = append(out, []string{"p", pk, trimString(m["name"], 200), trimString(m["picture"], 2000), nip05})221 }222 return out, ""223}224225func (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] = true229 }230 if members, err := s.members(ctx); err == nil {231 for _, member := range members {232 allowed[member.PubKey] = true233 }234 }235 byEvent, byAuthor := map[string]int64{}, map[string]int64{}236 for _, receipt := range events {237 if receipt.Kind != 9735 {238 continue239 }240 var req event.Event241 if json.Unmarshal([]byte(event.Tag(receipt, "description")), &req) != nil || req.Kind != 9734 {242 continue243 }244 msats := bolt11MSats(event.Tag(receipt, "bolt11"))245 if msats <= 0 {246 continue247 }248 if pk := event.Tag(req, "p"); allowed[pk] {249 byAuthor[pk] += msats250 }251 if id := event.Tag(req, "e"); len(id) == 64 {252 var exists int253 if s.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE id=?`, id).Scan(&exists) == nil {254 byEvent[id] += msats255 }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 > nj270 }271 return out[i][1] < out[j][1]272 })273 return out, ""274}275276func (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}287288func 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] = e295 }296 }297 if e.Kind == 10002 {298 if _, ok := relays[e.PubKey]; !ok {299 relays[e.PubKey] = e300 }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 continue309 }310 var m map[string]any311 _ = 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 continue323 }324 x, err := url.Parse(strings.TrimSpace(t[1]))325 if err != nil || (x.Scheme != "ws" && x.Scheme != "wss") || x.Host == "" {326 continue327 }328 u := strings.ToLower(x.Scheme + "://" + x.Host + strings.TrimRight(x.EscapedPath(), "/"))329 if x.RawQuery != "" {330 u += "?" + x.RawQuery331 }332 if seen[u] {333 continue334 }335 seen[u] = true336 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.Event357 at int64358 }359 var upcoming []cal360 for _, e := range events {361 if e.Kind != 31922 && e.Kind != 31923 {362 continue363 }364 at := calendarStart(e)365 if at < now-86400 || at > now+30*86400 {366 continue367 }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 continue375 }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] = true381 }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 continue427 }428 var req event.Event429 if json.Unmarshal([]byte(event.Tag(receipt, "description")), &req) != nil || req.Kind != 9734 {430 continue431 }432 msats := bolt11MSats(event.Tag(receipt, "bolt11"))433 if msats <= 0 {434 continue435 }436 if target := event.Tag(req, "p"); target != "" {437 byAuthor[target] += msats438 }439 if target := event.Tag(req, "e"); len(target) == 64 {440 byEvent[target] += msats441 }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 > nj455 }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}465466// 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 0470 }471 s := strings.ToLower(invoice[4:])472 i := 0473 for i < len(s) && s[i] >= '0' && s[i] <= '9' {474 i++475 }476 if i == 0 {477 return 0478 }479 amount := s[:i]480 suffix := ""481 if i < len(s) {482 suffix = s[i : i+1]483 }484 var factor float64485 switch suffix {486 case "m":487 factor = 1e8488 case "u":489 factor = 1e5490 case "n":491 factor = 1e2492 case "p":493 factor = 0.1494 case "":495 factor = 1e11496 default:497 return 0498 }499 var value float64500 if _, err := fmt.Sscanf(amount, "%f", &value); err != nil {501 return 0502 }503 value *= factor504 if value <= 0 || value > math.MaxInt64 {505 return 0506 }507 return int64(value + 0.5)508}509510// 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 nil515 }516 var dirty int64517 if err := s.store.GetSetting(ctx, "records.view."+name+".dirty", &dirty); err != nil && !errors.Is(err, sql.ErrNoRows) {518 return err519 }520 if dirty == 0 {521 return s.store.PutSetting(ctx, "records.view."+name+".dirty", now)522 }523 return nil524}525526// 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 continue535 }536 if mode == "write" {537 period = 86400538 }539 if mode == "hourly" {540 period = 3600541 }542 if mode == "daily" {543 period = 86400544 }545 if mode == "write" {546 var dirty int64547 if err := s.store.GetSetting(ctx, "records.view."+name+".dirty", &dirty); err != nil && !errors.Is(err, sql.ErrNoRows) {548 return 0, err549 }550 if dirty != 0 {551 due := dirty + 10552 if due <= now {553 return now, nil554 }555 if next == 0 || due < next {556 next = due557 }558 continue559 }560 }561 var last int64562 if err := s.store.GetSetting(ctx, "records.view."+name+".at", &last); err != nil && !errors.Is(err, sql.ErrNoRows) {563 return 0, err564 }565 due := last + period566 if last == 0 || due <= now {567 return now, nil568 }569 if next == 0 || due < next {570 next = due571 }572 }573 return next, nil574}575576func (s *Service) viewFingerprint(ctx context.Context, name string, now int64) (string, error) {577 var n, max sql.NullInt64578 var q string579 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 "", nil586 }587 if err := s.store.DB().QueryRowContext(ctx, q).Scan(&n, &max); err != nil {588 return "", err589 }590 day := int64(0)591 if name == "calendar" {592 day = now / 86400593 }594 return fmt.Sprintf("%d:%d:%d", n.Int64, max.Int64, day), nil595}596597func (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 err600 }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 err603}604605// 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, err610 }611 defer rows.Close()612 out := make([]ViewRun, 0, 60)613 for rows.Next() {614 var r ViewRun615 if err := rows.Scan(&r.At, &r.Rows); err != nil {616 return nil, err617 }618 out = append(out, r)619 }620 return out, rows.Err()621}622623func (s *Service) ViewRowsSince(ctx context.Context, name string, since int64) (int64, error) {624 var total sql.NullInt64625 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, err628 }629 return total.Int64, nil630}631632func (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, err640 }641 var last any642 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, nil657}658659func (s *Service) ViewNames() []string { return append([]string(nil), viewNames...) }