| 1 | package daemon |
| 2 | |
| 3 | // NIP-54 wiki rules that sit on top of stored events: how a merge request is |
| 4 | // read, how its answer is derived from the destination author's reactions, |
| 5 | // and the device notice sent when one arrives. The relay never merges on |
| 6 | // its own; an accepted merge request is applied when the destination author |
| 7 | // publishes a new version of their article. |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "net/url" |
| 12 | "sort" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 18 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 19 | "github.com/FelineStateMachine/tinyrelay/internal/wiki" |
| 20 | ) |
| 21 | |
| 22 | const ( |
| 23 | kindWikiArticle = 30818 |
| 24 | kindWikiMerge = 818 |
| 25 | kindWikiRedirect = 30819 |
| 26 | kindReaction = 7 |
| 27 | wikiQueryLimit = 2000 |
| 28 | ) |
| 29 | |
| 30 | const ( |
| 31 | wikiMergeOpen = "open" |
| 32 | wikiMergeAccepted = "accepted" |
| 33 | wikiMergeRejected = "rejected" |
| 34 | ) |
| 35 | |
| 36 | type wikiRef struct { |
| 37 | Coordinate string `json:"a,omitempty"` |
| 38 | Event string `json:"e,omitempty"` |
| 39 | } |
| 40 | |
| 41 | type wikiVersion struct { |
| 42 | ID string `json:"id"` |
| 43 | Author string `json:"author"` |
| 44 | CreatedAt int64 `json:"created_at"` |
| 45 | D string `json:"d"` |
| 46 | Title string `json:"title"` |
| 47 | Summary string `json:"summary,omitempty"` |
| 48 | Coordinate string `json:"coordinate"` |
| 49 | Likes int `json:"likes"` |
| 50 | Fork *wikiRef `json:"fork,omitempty"` |
| 51 | Defer *wikiRef `json:"defer,omitempty"` |
| 52 | Content string `json:"content,omitempty"` |
| 53 | Links []string `json:"links,omitempty"` |
| 54 | // Revision numbers this event among the author's revisions of the |
| 55 | // page, counted from 1 at the oldest kept revision; Revisions is how |
| 56 | // many the author has. SupersededBy names the event that replaced an |
| 57 | // archived revision and is empty for the author's current version. |
| 58 | Revision int `json:"revision,omitempty"` |
| 59 | Revisions int `json:"revisions,omitempty"` |
| 60 | SupersededBy string `json:"superseded_by,omitempty"` |
| 61 | // Proposal marks a version from an agent whose grant says wiki: |
| 62 | // propose. Approval is pending, approved or rejected; the other fields |
| 63 | // name the deciding reaction, its time and its author once one exists. |
| 64 | Proposal bool `json:"proposal,omitempty"` |
| 65 | Approval string `json:"approval,omitempty"` |
| 66 | ApprovalEvent string `json:"approval_event,omitempty"` |
| 67 | ApprovalAt int64 `json:"approval_at,omitempty"` |
| 68 | ApprovalBy string `json:"approval_by,omitempty"` |
| 69 | } |
| 70 | |
| 71 | type wikiAnswer struct { |
| 72 | Status string `json:"status"` |
| 73 | AnsweredAt int64 `json:"answered_at,omitempty"` |
| 74 | Reaction string `json:"reaction,omitempty"` |
| 75 | } |
| 76 | |
| 77 | type wikiMerge struct { |
| 78 | ID string `json:"id"` |
| 79 | Author string `json:"author"` |
| 80 | CreatedAt int64 `json:"created_at"` |
| 81 | Content string `json:"content"` |
| 82 | Target string `json:"target"` |
| 83 | TargetD string `json:"target_d"` |
| 84 | Destination string `json:"destination"` |
| 85 | Base string `json:"base,omitempty"` |
| 86 | Source string `json:"source,omitempty"` |
| 87 | wikiAnswer |
| 88 | } |
| 89 | |
| 90 | type wikiRedirect struct { |
| 91 | ID string `json:"id"` |
| 92 | Author string `json:"author"` |
| 93 | CreatedAt int64 `json:"created_at"` |
| 94 | D string `json:"d"` |
| 95 | Target string `json:"target"` |
| 96 | TargetD string `json:"target_d"` |
| 97 | TargetAuthor string `json:"target_author"` |
| 98 | } |
| 99 | |
| 100 | // wikiCoordinate splits a "30818:<pubkey>:<d>" reference. |
| 101 | func wikiCoordinate(value string) (author, d string, ok bool) { |
| 102 | parts := strings.SplitN(value, ":" , 3) |
| 103 | if len(parts) != 3 || parts[0] != strconv.Itoa(kindWikiArticle) || len(parts[1]) != 64 || parts[2] == "" { |
| 104 | return "" , "" , false |
| 105 | } |
| 106 | return parts[1], parts[2], true |
| 107 | } |
| 108 | |
| 109 | func wikiVersionFrom(e event.Event, full bool) wikiVersion { |
| 110 | d := event.Tag(e, "d" ) |
| 111 | v := wikiVersion{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, D: d, Title: event.Tag(e, "title" ), Summary: event.Tag(e, "summary" ), Coordinate: strconv.Itoa(kindWikiArticle) + ":" + e.PubKey + ":" + d} |
| 112 | if v.Title == "" { |
| 113 | v.Title = d |
| 114 | } |
| 115 | for _, tag := range e.Tags { |
| 116 | if len(tag) < 4 || (tag[3] != "fork" && tag[3] != "defer" ) || (tag[0] != "a" && tag[0] != "e" ) { |
| 117 | continue |
| 118 | } |
| 119 | ref := &v.Fork |
| 120 | if tag[3] == "defer" { |
| 121 | ref = &v.Defer |
| 122 | } |
| 123 | if *ref == nil { |
| 124 | *ref = &wikiRef{} |
| 125 | } |
| 126 | if tag[0] == "a" { |
| 127 | (*ref).Coordinate = tag[1] |
| 128 | } else { |
| 129 | (*ref).Event = tag[1] |
| 130 | } |
| 131 | } |
| 132 | if full { |
| 133 | v.Content = e.Content |
| 134 | v.Links = wiki.Links(e.Content) |
| 135 | } |
| 136 | return v |
| 137 | } |
| 138 | |
| 139 | // wikiMergeFrom reads a kind 818 request. The proposed version is the e tag |
| 140 | // with the source marker, or a source tag, or the only plain e tag; when two |
| 141 | // plain e tags are present the first is the base and the last the proposal. |
| 142 | func wikiMergeFrom(e event.Event) wikiMerge { |
| 143 | m := wikiMerge{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, Content: e.Content, Target: event.Tag(e, "a" ), wikiAnswer: wikiAnswer{Status: wikiMergeOpen}} |
| 144 | m.Destination, m.TargetD, _ = wikiCoordinate(m.Target) |
| 145 | if p := event.Tag(e, "p" ); len(p) == 64 { |
| 146 | m.Destination = p |
| 147 | } |
| 148 | var plain []string |
| 149 | for _, tag := range e.Tags { |
| 150 | if len(tag) < 2 || tag[0] != "e" || len(tag[1]) != 64 { |
| 151 | continue |
| 152 | } |
| 153 | if len(tag) >= 4 && tag[3] == "source" { |
| 154 | m.Source = tag[1] |
| 155 | } else { |
| 156 | plain = append(plain, tag[1]) |
| 157 | } |
| 158 | } |
| 159 | if m.Source == "" { |
| 160 | if source := event.Tag(e, "source" ); len(source) == 64 { |
| 161 | m.Source = source |
| 162 | } |
| 163 | } |
| 164 | switch { |
| 165 | case m.Source == "" && len(plain) == 1: |
| 166 | m.Source = plain[0] |
| 167 | case m.Source == "" && len(plain) > 1: |
| 168 | m.Base, m.Source = plain[0], plain[len(plain)-1] |
| 169 | case len(plain) > 0: |
| 170 | m.Base = plain[0] |
| 171 | } |
| 172 | return m |
| 173 | } |
| 174 | |
| 175 | func wikiRedirectFrom(e event.Event) wikiRedirect { |
| 176 | r := wikiRedirect{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, D: event.Tag(e, "d" ), Target: event.Tag(e, "a" )} |
| 177 | r.TargetAuthor, r.TargetD, _ = wikiCoordinate(r.Target) |
| 178 | return r |
| 179 | } |
| 180 | |
| 181 | // wikiMergeStatus reports how the destination author answered a merge |
| 182 | // request: a NIP-25 reaction from that pubkey tagging the request with "+" |
| 183 | // accepts it and "-" rejects it. The newest reaction wins. |
| 184 | func (t *Tenant) wikiMergeStatus(ctx context.Context, mergeRequestID, destination string) (wikiAnswer, error) { |
| 185 | answers, err := t.wikiMergeStatuses(ctx, map[string]string{mergeRequestID: destination}) |
| 186 | if err != nil { |
| 187 | return wikiAnswer{}, err |
| 188 | } |
| 189 | return answers[mergeRequestID], nil |
| 190 | } |
| 191 | |
| 192 | // wikiMergeStatuses resolves a set of merge requests, keyed by request id |
| 193 | // with the destination pubkey as value, in one query. |
| 194 | func (t *Tenant) wikiMergeStatuses(ctx context.Context, destinations map[string]string) (map[string]wikiAnswer, error) { |
| 195 | answers := make(map[string]wikiAnswer, len(destinations)) |
| 196 | ids := make([]string, 0, len(destinations)) |
| 197 | authors := make([]string, 0, len(destinations)) |
| 198 | for id, destination := range destinations { |
| 199 | answers[id] = wikiAnswer{Status: wikiMergeOpen} |
| 200 | ids = append(ids, id) |
| 201 | authors = append(authors, destination) |
| 202 | } |
| 203 | if len(ids) == 0 { |
| 204 | return answers, nil |
| 205 | } |
| 206 | rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Authors: uniqueStrings(authors), Tags: map[string][]string{"e" : ids}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit}) |
| 207 | if err != nil { |
| 208 | return nil, err |
| 209 | } |
| 210 | reactions := rows.Events |
| 211 | sortEventsNewestFirst(reactions) |
| 212 | for _, reaction := range reactions { |
| 213 | status := wikiReactionStatus(reaction.Content) |
| 214 | if status == "" { |
| 215 | continue |
| 216 | } |
| 217 | for _, id := range event.TagValues(reaction, "e" ) { |
| 218 | if destinations[id] != reaction.PubKey || answers[id].Status != wikiMergeOpen { |
| 219 | continue |
| 220 | } |
| 221 | answers[id] = wikiAnswer{Status: status, AnsweredAt: reaction.CreatedAt, Reaction: reaction.ID} |
| 222 | } |
| 223 | } |
| 224 | return answers, nil |
| 225 | } |
| 226 | |
| 227 | func wikiReactionStatus(content string) string { |
| 228 | switch strings.TrimSpace(content) { |
| 229 | case "+" , "" : |
| 230 | return wikiMergeAccepted |
| 231 | case "-" : |
| 232 | return wikiMergeRejected |
| 233 | } |
| 234 | return "" |
| 235 | } |
| 236 | |
| 237 | // wikiMemberLikes counts "+" reactions from the owner and members for each |
| 238 | // version, matched by event id or article coordinate. |
| 239 | func (t *Tenant) wikiMemberLikes(ctx context.Context, versions []wikiVersion) (map[string]int, error) { |
| 240 | likes := make(map[string]int, len(versions)) |
| 241 | if len(versions) == 0 { |
| 242 | return likes, nil |
| 243 | } |
| 244 | ids := make([]string, 0, len(versions)) |
| 245 | coordinates := make([]string, 0, len(versions)) |
| 246 | byCoordinate := make(map[string]string, len(versions)) |
| 247 | for _, v := range versions { |
| 248 | likes[v.ID] = 0 |
| 249 | ids = append(ids, v.ID) |
| 250 | coordinates = append(coordinates, v.Coordinate) |
| 251 | byCoordinate[v.Coordinate] = v.ID |
| 252 | } |
| 253 | var reactions []event.Event |
| 254 | for _, tags := range []map[string][]string{{"e" : ids}, {"a" : coordinates}} { |
| 255 | rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Tags: tags}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit}) |
| 256 | if err != nil { |
| 257 | return nil, err |
| 258 | } |
| 259 | reactions = append(reactions, rows.Events...) |
| 260 | } |
| 261 | members := map[string]bool{t.Policy().Owner: true} |
| 262 | counted := map[string]bool{} |
| 263 | for _, reaction := range uniqueEvents(reactions) { |
| 264 | if wikiReactionStatus(reaction.Content) != wikiMergeAccepted { |
| 265 | continue |
| 266 | } |
| 267 | if _, ok := members[reaction.PubKey]; !ok { |
| 268 | member, err := t.community.IsMember(ctx, reaction.PubKey) |
| 269 | if err != nil { |
| 270 | return nil, err |
| 271 | } |
| 272 | members[reaction.PubKey] = member |
| 273 | } |
| 274 | if !members[reaction.PubKey] { |
| 275 | continue |
| 276 | } |
| 277 | targets := event.TagValues(reaction, "e" ) |
| 278 | for _, coordinate := range event.TagValues(reaction, "a" ) { |
| 279 | if id, ok := byCoordinate[coordinate]; ok { |
| 280 | targets = append(targets, id) |
| 281 | } |
| 282 | } |
| 283 | for _, id := range targets { |
| 284 | key := reaction.PubKey + "\x00" + id |
| 285 | if _, known := likes[id]; !known || counted[key] { |
| 286 | continue |
| 287 | } |
| 288 | counted[key] = true |
| 289 | likes[id]++ |
| 290 | } |
| 291 | } |
| 292 | return likes, nil |
| 293 | } |
| 294 | |
| 295 | // wikiPreferred picks the version to show: the requested author, else the |
| 296 | // tenant owner, else the most member likes, else the newest. Versions must |
| 297 | // already be sorted newest first. |
| 298 | func wikiPreferred(versions []wikiVersion, author, owner string) (wikiVersion, string) { |
| 299 | for _, v := range versions { |
| 300 | if author != "" && v.Author == author { |
| 301 | return v, "author" |
| 302 | } |
| 303 | } |
| 304 | for _, v := range versions { |
| 305 | if v.Author == owner { |
| 306 | return v, "owner" |
| 307 | } |
| 308 | } |
| 309 | best := versions[0] |
| 310 | for _, v := range versions[1:] { |
| 311 | if v.Likes > best.Likes { |
| 312 | best = v |
| 313 | } |
| 314 | } |
| 315 | if best.Likes > 0 { |
| 316 | return best, "reactions" |
| 317 | } |
| 318 | return versions[0], "newest" |
| 319 | } |
| 320 | |
| 321 | func sortWikiVersions(versions []wikiVersion) { |
| 322 | sort.Slice(versions, func(i, j int) bool { |
| 323 | if versions[i].CreatedAt == versions[j].CreatedAt { |
| 324 | return versions[i].ID < versions[j].ID |
| 325 | } |
| 326 | return versions[i].CreatedAt > versions[j].CreatedAt |
| 327 | }) |
| 328 | } |
| 329 | |
| 330 | // wikiNumberRevisions numbers every version in the groups among the |
| 331 | // revisions of the same author and page, oldest first from 1, and sets |
| 332 | // the count on each. The groups together must hold every kept revision |
| 333 | // so the numbers are the same for every viewer. |
| 334 | func wikiNumberRevisions(groups ...[]wikiVersion) { |
| 335 | pages := map[string][]*wikiVersion{} |
| 336 | for _, group := range groups { |
| 337 | for i := range group { |
| 338 | key := group[i].Author + "\x00" + group[i].D |
| 339 | pages[key] = append(pages[key], &group[i]) |
| 340 | } |
| 341 | } |
| 342 | for _, revisions := range pages { |
| 343 | sort.SliceStable(revisions, func(i, j int) bool { |
| 344 | if revisions[i].CreatedAt == revisions[j].CreatedAt { |
| 345 | return revisions[i].ID < revisions[j].ID |
| 346 | } |
| 347 | return revisions[i].CreatedAt < revisions[j].CreatedAt |
| 348 | }) |
| 349 | for i, v := range revisions { |
| 350 | v.Revision, v.Revisions = i+1, len(revisions) |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | // wikiFull reads the content of an event into a version while keeping the |
| 356 | // derived fields of the listed version: likes, revision numbers and the |
| 357 | // proposal state. |
| 358 | func wikiFull(e event.Event, v wikiVersion) wikiVersion { |
| 359 | full := wikiVersionFrom(e, true) |
| 360 | full.Likes, full.Revision, full.Revisions, full.SupersededBy = v.Likes, v.Revision, v.Revisions, v.SupersededBy |
| 361 | full.Proposal, full.Approval, full.ApprovalEvent, full.ApprovalAt, full.ApprovalBy = v.Proposal, v.Approval, v.ApprovalEvent, v.ApprovalAt, v.ApprovalBy |
| 362 | return full |
| 363 | } |
| 364 | |
| 365 | // notifyWikiMerge wakes the destination author's devices when a merge |
| 366 | // request for one of their articles arrives from someone else. |
| 367 | func (t *Tenant) notifyWikiMerge(ctx context.Context, e event.Event) { |
| 368 | if e.Kind != kindWikiMerge || !t.pushDevicesExist(ctx) { |
| 369 | return |
| 370 | } |
| 371 | m := wikiMergeFrom(e) |
| 372 | if m.Destination == "" || m.Destination == e.PubKey || m.TargetD == "" { |
| 373 | return |
| 374 | } |
| 375 | role, err := t.community.Role(ctx, m.Destination) |
| 376 | if err != nil || role == "" { |
| 377 | return |
| 378 | } |
| 379 | if !t.pushCoalesced(m.Destination, pushReplies) { |
| 380 | return |
| 381 | } |
| 382 | title := m.TargetD |
| 383 | rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindWikiArticle}, Authors: []string{m.Destination}, Tags: map[string][]string{"d" : {m.TargetD}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1}) |
| 384 | if err == nil && len(rows.Events) == 1 { |
| 385 | title = wikiVersionFrom(rows.Events[0], false).Title |
| 386 | } |
| 387 | notice := pushNotice{recipient: m.Destination, category: pushReplies, body: "Merge request for " + excerpt(title), url: strings.TrimRight(t.publicURL, "/" ) + "/wiki/" + url.PathEscape(m.TargetD) + "?merge=" + e.ID} |
| 388 | if err := t.enqueuePushNotice(ctx, notice); err != nil { |
| 389 | t.app.telemetry.Logger().Debug("wiki merge notification not queued" , "error" , err) |
| 390 | } |
| 391 | } |