clone

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

hold wiki versions from propose agents until the owner or a moderator approves them

Dami committed 0dbee91
commit
0dbee9115994a504fda9c80bbff28241f4d607c8
parent
46bd1db112dadf7d1e3efe0c960a4206aaeab5c8
author
Dami <dami.etoile@gmail.com>
6 files changed, +273 -6
Minternal/community/community.go18+++
Minternal/daemon/services.go1+
Minternal/daemon/tenant.go1+
Minternal/daemon/wiki.go8+
Ainternal/daemon/wiki_approvals.go207++++++++++++++++++++++++++++++
Minternal/daemon/wiki_browse.go44++++++-
M internal/community/community.go +18 -0
M internal/daemon/services.go +1 -0
M internal/daemon/tenant.go +1 -0
M internal/daemon/wiki.go +8 -0
A internal/daemon/wiki_approvals.go +207 -0
@@ -0,0 +1,207 @@
1+package daemon2+3+// Wiki proposals. A version (kind 30818) whose author holds an agent grant4+// with wiki set to propose is a proposal: it stays pending until the relay5+// owner or a moderator reacts to the version's event id with "+", which6+// approves it, or "-", which rejects it. A newer version from the agent is7+// a new event id and starts pending again, so every edit is decided on its8+// own. A pending or rejected proposal is visible only to the owner,9+// moderators and its author; for everyone else it does not exist. The10+// state is computed here and carried on every browse result that lists11+// versions.12+13+import (14+ "context"15+ "net/url"16+ "strings"17+ "time"18+19+ "github.com/FelineStateMachine/tinyrelay/internal/event"20+ "github.com/FelineStateMachine/tinyrelay/internal/storage"21+)22+23+const (24+ wikiProposalPending = "pending"25+ wikiProposalApproved = "approved"26+ wikiProposalRejected = "rejected"27+ wikiGrantPropose = "propose"28+)29+30+// wikiRoles caches the role and grant lookups one browse call makes, since31+// a page's versions and their reactions repeat the same few keys.32+type wikiRoles struct {33+ t *Tenant34+ roles map[string]string35+ proposers map[string]bool36+}37+38+func (t *Tenant) wikiRoles() *wikiRoles {39+ return &wikiRoles{t: t, roles: map[string]string{}, proposers: map[string]bool{}}40+}41+42+func (r *wikiRoles) role(ctx context.Context, pubkey string) (string, error) {43+ if role, ok := r.roles[pubkey]; ok {44+ return role, nil45+ }46+ role, err := r.t.community.Role(ctx, pubkey)47+ if err != nil {48+ return "", err49+ }50+ r.roles[pubkey] = role51+ return role, nil52+}53+54+// decides reports whether a key may approve or reject proposals: the relay55+// owner and moderators.56+func (r *wikiRoles) decides(ctx context.Context, pubkey string) (bool, error) {57+ if pubkey == "" {58+ return false, nil59+ }60+ role, err := r.role(ctx, pubkey)61+ return wikiDecidingRole(role), err62+}63+64+func wikiDecidingRole(role string) bool {65+ return role == "owner" || role == "moderator"66+}67+68+// proposer reports whether versions from a key are proposals: the key holds69+// an agent grant with wiki set to propose and no human standing on the70+// relay. A paused, revoked or expired grant keeps counting, so an agent's71+// pending versions do not surface when its grant ends.72+func (r *wikiRoles) proposer(ctx context.Context, pubkey string) (bool, error) {73+ if known, ok := r.proposers[pubkey]; ok {74+ return known, nil75+ }76+ grant, ok, err := r.t.community.AgentGrant(ctx, pubkey)77+ if err != nil {78+ return false, err79+ }80+ proposer := ok && grant.Scope.Wiki == wikiGrantPropose81+ if proposer {82+ role, err := r.role(ctx, pubkey)83+ if err != nil {84+ return false, err85+ }86+ proposer = role == "" || role == "agent"87+ }88+ r.proposers[pubkey] = proposer89+ return proposer, nil90+}91+92+// wikiProposals marks the proposals among versions and resolves each one's93+// state from the owner's and moderators' reactions to its event id. The94+// newest deciding reaction counts.95+func (t *Tenant) wikiProposals(ctx context.Context, roles *wikiRoles, versions []wikiVersion) error {96+ var ids []string97+ for i := range versions {98+ proposer, err := roles.proposer(ctx, versions[i].Author)99+ if err != nil {100+ return err101+ }102+ if !proposer {103+ continue104+ }105+ versions[i].Proposal = true106+ versions[i].Approval = wikiProposalPending107+ ids = append(ids, versions[i].ID)108+ }109+ if len(ids) == 0 {110+ return nil111+ }112+ rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})113+ if err != nil {114+ return err115+ }116+ reactions := rows.Events117+ sortEventsNewestFirst(reactions)118+ for _, reaction := range reactions {119+ state := wikiProposalState(reaction.Content)120+ if state == "" {121+ continue122+ }123+ decides, err := roles.decides(ctx, reaction.PubKey)124+ if err != nil {125+ return err126+ }127+ if !decides {128+ continue129+ }130+ for _, id := range event.TagValues(reaction, "e") {131+ for i := range versions {132+ v := &versions[i]133+ if v.ID != id || !v.Proposal || v.ApprovalEvent != "" {134+ continue135+ }136+ v.Approval, v.ApprovalEvent, v.ApprovalAt, v.ApprovalBy = state, reaction.ID, reaction.CreatedAt, reaction.PubKey137+ }138+ }139+ }140+ return nil141+}142+143+func wikiProposalState(content string) string {144+ switch wikiReactionStatus(content) {145+ case wikiMergeAccepted:146+ return wikiProposalApproved147+ case wikiMergeRejected:148+ return wikiProposalRejected149+ }150+ return ""151+}152+153+// wikiVisible keeps the versions the actor may see: every version that is154+// not a proposal or is approved, plus pending and rejected proposals for155+// the owner, moderators and the proposal's author.156+func (t *Tenant) wikiVisible(ctx context.Context, roles *wikiRoles, actor string, versions []wikiVersion) ([]wikiVersion, error) {157+ decides, err := roles.decides(ctx, actor)158+ if err != nil {159+ return nil, err160+ }161+ visible := make([]wikiVersion, 0, len(versions))162+ for _, v := range versions {163+ if !v.Proposal || v.Approval == wikiProposalApproved || decides || (actor != "" && v.Author == actor) {164+ visible = append(visible, v)165+ }166+ }167+ return visible, nil168+}169+170+// notifyWikiProposal wakes the owner's and moderators' devices in the171+// approvals category when a proposal arrives. A decision wakes nobody.172+func (t *Tenant) notifyWikiProposal(ctx context.Context, e event.Event) {173+ if e.Kind != kindWikiArticle || !t.pushDevicesExist(ctx) {174+ return175+ }176+ roles := t.wikiRoles()177+ proposer, err := roles.proposer(ctx, e.PubKey)178+ if err != nil || !proposer {179+ return180+ }181+ v := wikiVersionFrom(e, false)182+ if v.D == "" {183+ return184+ }185+ name := shortKey(e.PubKey)186+ if grant, ok, err := t.community.AgentGrant(ctx, e.PubKey); err == nil && ok && strings.TrimSpace(grant.Name) != "" {187+ name = strings.TrimSpace(grant.Name)188+ }189+ moderators, err := t.community.Moderators(ctx)190+ if err != nil {191+ return192+ }193+ notice := pushNotice{category: pushApprovals, body: name + " proposes wiki: " + excerpt(v.Title), url: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(v.D)}194+ queued := 0195+ for _, recipient := range uniqueStrings(append([]string{t.Policy().Owner}, moderators...)) {196+ if recipient == e.PubKey || !t.pushCoalesced(recipient, pushApprovals) {197+ continue198+ }199+ notice.recipient = recipient200+ if err := t.enqueuePushNotice(ctx, notice); err != nil {201+ t.app.telemetry.Logger().Debug("wiki proposal notification not queued", "error", err)202+ continue203+ }204+ queued++205+ }206+ t.app.telemetry.Logger().Debug("wiki proposal notifications queued", "count", queued)207+}
M internal/daemon/wiki_browse.go +38 -6
@@ -49,6 +49,8 @@ type wikiPage struct {
4949 Merges []wikiMerge `json:"merges"`5050 RedirectsTo []wikiRedirect `json:"redirects_to"`5151 RedirectsFrom []wikiRedirect `json:"redirects_from"`52+ // CanApprove reports whether the caller may accept or reject proposals.53+ CanApprove bool `json:"can_approve"`5254 }5355 5456 type wikiMergeDetail struct {
@@ -110,7 +112,10 @@ func (t *Tenant) wikiQuery(ctx context.Context, session relay.Session, filters .
110112 return rows, nil111113 }112114 113-func (t *Tenant) wikiVersions(ctx context.Context, session relay.Session, filter event.Filter) ([]wikiVersion, error) {115+// wikiVersions lists the versions the actor may see, with each proposal's116+// state filled in; pending and rejected proposals stay in the list only117+// for the owner, moderators and their author.118+func (t *Tenant) wikiVersions(ctx context.Context, roles *wikiRoles, actor string, session relay.Session, filter event.Filter) ([]wikiVersion, error) {114119 filter.Kinds = []int{kindWikiArticle}115120 rows, err := t.wikiQuery(ctx, session, filter)116121 if err != nil {
@@ -122,7 +127,10 @@ func (t *Tenant) wikiVersions(ctx context.Context, session relay.Session, filter
122127 versions = append(versions, wikiVersionFrom(row, false))123128 }124129 }125- return versions, nil130+ if err := t.wikiProposals(ctx, roles, versions); err != nil {131+ return nil, err132+ }133+ return t.wikiVisible(ctx, roles, actor, versions)126134 }127135 128136 // wikiMerges lists the merge requests aimed at any of the coordinates with
@@ -162,7 +170,12 @@ func wikiMatches(v wikiVersion, query string) bool {
162170 163171 func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {164172 session := browseSession(t, actor)165- versions, err := t.wikiVersions(ctx, session, event.Filter{})173+ roles := t.wikiRoles()174+ versions, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{})175+ if err != nil {176+ return nil, err177+ }178+ decides, err := roles.decides(ctx, actor)166179 if err != nil {167180 return nil, err168181 }
@@ -203,7 +216,7 @@ func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseReque
203216 }204217 items = append(items, wikiPageItem{D: d, Title: preferred.Title, Summary: preferred.Summary, Version: preferred, Versions: len(page), OpenMerges: open})205218 }206- return map[string]any{"items": items, "next_cursor": next}, nil219+ return map[string]any{"items": items, "next_cursor": next, "can_approve": decides}, nil207220 }208221 209222 // wikiRankVersions fills in member likes when they decide the preferred
@@ -239,7 +252,12 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
239252 return nil, errors.New("invalid: wiki page name")240253 }241254 session := browseSession(t, actor)242- versions, err := t.wikiVersions(ctx, session, event.Filter{Tags: map[string][]string{"d": {d}}})255+ roles := t.wikiRoles()256+ versions, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{Tags: map[string][]string{"d": {d}}})257+ if err != nil {258+ return nil, err259+ }260+ decides, err := roles.decides(ctx, actor)243261 if err != nil {244262 return nil, err245263 }
@@ -247,7 +265,7 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
247265 if err != nil {248266 return nil, err249267 }250- page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}}268+ page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}, CanApprove: decides}251269 for _, row := range redirects {252270 r := wikiRedirectFrom(row)253271 if r.D == d && r.TargetD != "" && r.TargetD != d {
@@ -290,6 +308,7 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
290308 if len(rows) == 1 {291309 full := wikiVersionFrom(rows[0], true)292310 full.Likes = preferred.Likes311+ full.Proposal, full.Approval, full.ApprovalEvent, full.ApprovalAt, full.ApprovalBy = preferred.Proposal, preferred.Approval, preferred.ApprovalEvent, preferred.ApprovalAt, preferred.ApprovalBy293312 preferred = full294313 }295314 page.Title = preferred.Title
@@ -342,5 +361,18 @@ func (t *Tenant) browseWikiMerge(ctx context.Context, actor string, q wikiBrowse
342361 detail.Target = &v343362 }344363 }364+ // The versions keep their proposal state; a merge request's reader365+ // must see the proposed content to answer it, so nothing is hidden.366+ roles := t.wikiRoles()367+ for _, v := range []*wikiVersion{detail.Proposed, detail.Target} {368+ if v == nil {369+ continue370+ }371+ marked := []wikiVersion{*v}372+ if err := t.wikiProposals(ctx, roles, marked); err != nil {373+ return nil, err374+ }375+ *v = marked[0]376+ }345377 return detail, nil346378 }