clone

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

feat/tinyagent
tinyrelay / internal / daemon / git_outbox.go
git_outbox.go 7.4K
1package daemon23import (4 "context"5 "errors"6 "net/url"7 "sort"8 "strconv"9 "strings"10 "time"1112 "github.com/FelineStateMachine/tinyrelay/internal/event"13 "github.com/FelineStateMachine/tinyrelay/internal/replication"14)1516const (17 gitOutboxAuthorLimit = 3218 gitOutboxRelayLimit = 2419)2021// gitOutboxOptions describes the bounded, read-only part of GRASP-03. Seeds22// are the repository's announced relays. Private repositories must also set23// PrivatePeers; discovered public relays are never used for private work.24type gitOutboxOptions struct {25 Transport replication.PullTransport26 Seeds []string27 Private bool28 PrivatePeers []string29 AuthorOffset int30 SeedOffset int31}3233// discoverGitOutboxes asks announced relays for the NIP-65 lists of authors34// who took part in a repository conversation. A failed author or seed is35// isolated so one unavailable relay cannot prevent the remaining discussion36// from being imported.37func discoverGitOutboxes(ctx context.Context, options gitOutboxOptions, authors []string) ([]string, error) {38 items, err := discoverGitOutboxEvents(ctx, options, authors)39 result := make([]string, 0, gitOutboxRelayLimit)40 seen := make(map[string]struct{}, gitOutboxRelayLimit)41 for _, item := range items {42 for _, relay := range gitOutboxRelayTags(item) {43 if !gitOutboxAllowedRelay(relay, options.Private, options.PrivatePeers) {44 continue45 }46 canonical := normalizeRelayGit(relay)47 if _, ok := seen[canonical]; ok {48 continue49 }50 seen[canonical] = struct{}{}51 result = append(result, canonical)52 if len(result) == gitOutboxRelayLimit {53 return result, err54 }55 }56 }57 return result, err58}5960// discoverGitOutboxEvents returns the newest valid authored list of each61// supported kind. Callers should ingest these events with OriginImport so the62// relay list becomes available after a restart and to normal replication.63func discoverGitOutboxEvents(ctx context.Context, options gitOutboxOptions, authors []string) ([]event.Event, error) {64 if options.Transport == nil {65 return nil, errors.New("GRASP-03: outbox transport is required")66 }67 authors = boundedGitAuthorsFrom(authors, options.AuthorOffset)68 seeds := boundedGitRelaysFrom(options.Seeds, options.Private, options.PrivatePeers, options.SeedOffset)69 if len(authors) == 0 || len(seeds) == 0 {70 return nil, nil71 }72 newest := make(map[string]event.Event, len(authors)*3)73 var queryErrors []error74 workCtx, cancel := context.WithTimeout(ctx, 5*time.Second)75 defer cancel()76 stop := false77 for _, seed := range seeds {78 for _, author := range authors {79 for _, kind := range []int{event.KIND_PROFILE, 10002, 10317} {80 if err := workCtx.Err(); err != nil {81 queryErrors = append(queryErrors, err)82 stop = true83 break84 }85 items, err := options.Transport.Query(workCtx, seed, gitOutboxFilter(author, kind))86 if err != nil {87 if len(queryErrors) < 8 {88 queryErrors = append(queryErrors, err)89 }90 continue91 }92 for _, item := range items {93 if event.Validate(item) != nil || item.PubKey != author || item.Kind != kind {94 continue95 }96 key := author + ":" + strconv.Itoa(item.Kind)97 if current, ok := newest[key]; !ok || item.CreatedAt > current.CreatedAt || (item.CreatedAt == current.CreatedAt && item.ID < current.ID) {98 newest[key] = item99 }100 }101 }102 if stop {103 break104 }105 }106 if stop {107 break108 }109 }110 result := make([]event.Event, 0, len(newest))111 for _, item := range newest {112 result = append(result, item)113 }114 sort.Slice(result, func(i, j int) bool {115 if result[i].PubKey == result[j].PubKey {116 return result[i].Kind < result[j].Kind117 }118 return result[i].PubKey < result[j].PubKey119 })120 return result, errors.Join(queryErrors...)121}122123func gitOutboxFilter(author string, kind int) event.Filter {124 limit := 1125 return event.Filter{Authors: []string{author}, Kinds: []int{kind}, Tags: map[string][]string{}, Limit: &limit}126}127128// gitConversationAuthors returns the repository participants whose outboxes129// may contain replies. The caller supplies events already admitted to the130// repository conversation, so arbitrary relay authors cannot expand the131// discovery set.132func gitConversationAuthors(events []event.Event, owner string, maintainers []string) []string {133 authors := make([]string, 0, len(maintainers)+1+len(events))134 appendAuthor := func(author string) {135 if len(author) != 64 || strings.Trim(author, "0123456789abcdefABCDEF") != "" {136 return137 }138 for _, existing := range authors {139 if existing == author {140 return141 }142 }143 authors = append(authors, author)144 }145 appendAuthor(owner)146 for _, maintainer := range maintainers {147 appendAuthor(maintainer)148 }149 for _, item := range events {150 switch item.Kind {151 case event.KIND_GIT_PATCH, event.KIND_GIT_PR, event.KIND_GIT_PR_UPDATE, event.KIND_GIT_ISSUE, 1111, 1630, 1631, 1632, 1633:152 appendAuthor(item.PubKey)153 }154 }155 return authors156}157158func boundedGitAuthors(authors []string) []string {159 return boundedGitAuthorsFrom(authors, 0)160}161162func boundedGitAuthorsFrom(authors []string, offset int) []string {163 seen := make(map[string]struct{}, len(authors))164 result := make([]string, 0, minGitInt(len(authors), gitOutboxAuthorLimit))165 if offset < 0 {166 offset = 0167 }168 valid := 0169 for _, author := range authors {170 if len(author) != 64 || strings.Trim(author, "0123456789abcdefABCDEF") != "" {171 continue172 }173 if _, ok := seen[author]; ok {174 continue175 }176 seen[author] = struct{}{}177 if valid < offset {178 valid++179 continue180 }181 valid++182 result = append(result, author)183 if len(result) == gitOutboxAuthorLimit {184 break185 }186 }187 return result188}189190func boundedGitRelays(relays []string, private bool, peers []string) []string {191 return boundedGitRelaysFrom(relays, private, peers, 0)192}193194func boundedGitRelaysFrom(relays []string, private bool, peers []string, offset int) []string {195 seen := make(map[string]struct{}, len(relays))196 valid := make([]string, 0, len(relays))197 for _, relay := range relays {198 if !gitOutboxAllowedRelay(relay, private, peers) {199 continue200 }201 canonical := normalizeRelayGit(relay)202 if _, ok := seen[canonical]; ok {203 continue204 }205 seen[canonical] = struct{}{}206 valid = append(valid, canonical)207 }208 if len(valid) == 0 {209 return nil210 }211 start := offset % len(valid)212 if start < 0 {213 start = 0214 }215 rotated := append(append([]string(nil), valid[start:]...), valid[:start]...)216 result := rotated217 if len(result) > gitOutboxRelayLimit {218 result = result[:gitOutboxRelayLimit]219 }220 return result221}222223func gitOutboxRelayTags(item event.Event) []string {224 name := ""225 switch item.Kind {226 case 10002:227 name = "r"228 case 10317:229 name = "g"230 default:231 return nil232 }233 result := make([]string, 0)234 for _, tag := range item.Tags {235 if len(tag) < 2 || tag[0] != name {236 continue237 }238 if item.Kind == 10002 && len(tag) > 2 && strings.EqualFold(tag[2], "read") {239 continue240 }241 result = append(result, tag[1])242 }243 return result244}245246func gitOutboxAllowedRelay(raw string, private bool, peers []string) bool {247 u, err := url.Parse(strings.TrimSpace(raw))248 if err != nil || u.User != nil || u.Fragment != "" || u.Host == "" {249 return false250 }251 if u.Scheme == "http" {252 u.Scheme = "ws"253 } else if u.Scheme == "https" {254 u.Scheme = "wss"255 }256 if (u.Scheme != "ws" && u.Scheme != "wss") || !safeGitOutboxHost(u.Hostname()) {257 return false258 }259 if !private {260 return true261 }262 return privatePeerMatch(u.String(), peers)263}264265func safeGitOutboxHost(host string) bool {266 return host != "" && !strings.ContainsAny(host, " \t\r\n")267}268269func minGitInt(a, b int) int {270 if a < b {271 return a272 }273 return b274}