clone

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

feat/standalone-relay
tinyrelay / internal / daemon / wiki.go
wiki.go 12.9K
1package daemon23// NIP-54 wiki rules that sit on top of stored events: how a merge request is4// 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 on6// its own; an accepted merge request is applied when the destination author7// publishes a new version of their article.89import (10 "context"11 "net/url"12 "sort"13 "strconv"14 "strings"15 "time"1617 "github.com/FelineStateMachine/tinyrelay/internal/event"18 "github.com/FelineStateMachine/tinyrelay/internal/storage"19 "github.com/FelineStateMachine/tinyrelay/internal/wiki"20)2122const (23 kindWikiArticle = 3081824 kindWikiMerge = 81825 kindWikiRedirect = 3081926 kindReaction = 727 wikiQueryLimit = 200028)2930const (31 wikiMergeOpen = "open"32 wikiMergeAccepted = "accepted"33 wikiMergeRejected = "rejected"34)3536type wikiRef struct {37 Coordinate string `json:"a,omitempty"`38 Event string `json:"e,omitempty"`39}4041type 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 the55 // page, counted from 1 at the oldest kept revision; Revisions is how56 // many the author has. SupersededBy names the event that replaced an57 // 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 fields63 // 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}7071type wikiAnswer struct {72 Status string `json:"status"`73 AnsweredAt int64 `json:"answered_at,omitempty"`74 Reaction string `json:"reaction,omitempty"`75}7677type 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 wikiAnswer88}8990type 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}99100// wikiCoordinate splits a "30818:<pubkey>:<d>" reference.101func 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 "", "", false105 }106 return parts[1], parts[2], true107}108109func 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 = d114 }115 for _, tag := range e.Tags {116 if len(tag) < 4 || (tag[3] != "fork" && tag[3] != "defer") || (tag[0] != "a" && tag[0] != "e") {117 continue118 }119 ref := &v.Fork120 if tag[3] == "defer" {121 ref = &v.Defer122 }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.Content134 v.Links = wiki.Links(e.Content)135 }136 return v137}138139// wikiMergeFrom reads a kind 818 request. The proposed version is the e tag140// with the source marker, or a source tag, or the only plain e tag; when two141// plain e tags are present the first is the base and the last the proposal.142func 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 = p147 }148 var plain []string149 for _, tag := range e.Tags {150 if len(tag) < 2 || tag[0] != "e" || len(tag[1]) != 64 {151 continue152 }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 = source162 }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 m173}174175func 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 r179}180181// wikiMergeStatus reports how the destination author answered a merge182// request: a NIP-25 reaction from that pubkey tagging the request with "+"183// accepts it and "-" rejects it. The newest reaction wins.184func (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{}, err188 }189 return answers[mergeRequestID], nil190}191192// wikiMergeStatuses resolves a set of merge requests, keyed by request id193// with the destination pubkey as value, in one query.194func (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, nil205 }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, err209 }210 reactions := rows.Events211 sortEventsNewestFirst(reactions)212 for _, reaction := range reactions {213 status := wikiReactionStatus(reaction.Content)214 if status == "" {215 continue216 }217 for _, id := range event.TagValues(reaction, "e") {218 if destinations[id] != reaction.PubKey || answers[id].Status != wikiMergeOpen {219 continue220 }221 answers[id] = wikiAnswer{Status: status, AnsweredAt: reaction.CreatedAt, Reaction: reaction.ID}222 }223 }224 return answers, nil225}226227func wikiReactionStatus(content string) string {228 switch strings.TrimSpace(content) {229 case "+", "":230 return wikiMergeAccepted231 case "-":232 return wikiMergeRejected233 }234 return ""235}236237// wikiMemberLikes counts "+" reactions from the owner and members for each238// version, matched by event id or article coordinate.239func (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, nil243 }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] = 0249 ids = append(ids, v.ID)250 coordinates = append(coordinates, v.Coordinate)251 byCoordinate[v.Coordinate] = v.ID252 }253 var reactions []event.Event254 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, err258 }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 continue266 }267 if _, ok := members[reaction.PubKey]; !ok {268 member, err := t.community.IsMember(ctx, reaction.PubKey)269 if err != nil {270 return nil, err271 }272 members[reaction.PubKey] = member273 }274 if !members[reaction.PubKey] {275 continue276 }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" + id285 if _, known := likes[id]; !known || counted[key] {286 continue287 }288 counted[key] = true289 likes[id]++290 }291 }292 return likes, nil293}294295// wikiPreferred picks the version to show: the requested author, else the296// tenant owner, else the most member likes, else the newest. Versions must297// already be sorted newest first.298func 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 = v313 }314 }315 if best.Likes > 0 {316 return best, "reactions"317 }318 return versions[0], "newest"319}320321func 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].ID325 }326 return versions[i].CreatedAt > versions[j].CreatedAt327 })328}329330// wikiNumberRevisions numbers every version in the groups among the331// revisions of the same author and page, oldest first from 1, and sets332// the count on each. The groups together must hold every kept revision333// so the numbers are the same for every viewer.334func 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].D339 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].ID346 }347 return revisions[i].CreatedAt < revisions[j].CreatedAt348 })349 for i, v := range revisions {350 v.Revision, v.Revisions = i+1, len(revisions)351 }352 }353}354355// wikiFull reads the content of an event into a version while keeping the356// derived fields of the listed version: likes, revision numbers and the357// proposal state.358func 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.SupersededBy361 full.Proposal, full.Approval, full.ApprovalEvent, full.ApprovalAt, full.ApprovalBy = v.Proposal, v.Approval, v.ApprovalEvent, v.ApprovalAt, v.ApprovalBy362 return full363}364365// notifyWikiMerge wakes the destination author's devices when a merge366// request for one of their articles arrives from someone else.367func (t *Tenant) notifyWikiMerge(ctx context.Context, e event.Event) {368 if e.Kind != kindWikiMerge || !t.pushDevicesExist(ctx) {369 return370 }371 m := wikiMergeFrom(e)372 if m.Destination == "" || m.Destination == e.PubKey || m.TargetD == "" {373 return374 }375 role, err := t.community.Role(ctx, m.Destination)376 if err != nil || role == "" {377 return378 }379 if !t.pushCoalesced(m.Destination, pushReplies) {380 return381 }382 title := m.TargetD383 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).Title386 }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}