tinyrelayfile
tinyrelay/internal/daemon/wiki_browse.go/

internal/daemon/wiki_browse.go raw
1package daemon
2
3// Wiki browsing: the page list, one page with its versions, the history
4// of its revisions, merge requests and redirects, and one merge request.
5// A version's earlier revisions come from the store's wiki_revisions
6// table, where a replaced kind 30818 event is kept. Every read passes the tenant gate
7// with the caller's session, so a members-only relay shows nothing to
8// guests, and each event is checked with CanSee before it is returned.
9
10import (
11 "context"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "sort"
16 "strings"
17 "time"
18
19 "github.com/FelineStateMachine/tinyrelay/internal/event"
20 "github.com/FelineStateMachine/tinyrelay/internal/relay"
21 "github.com/FelineStateMachine/tinyrelay/internal/storage"
22 "github.com/FelineStateMachine/tinyrelay/internal/wiki"
23)
24
25type wikiBrowseRequest struct {
26 D string `json:"d"`
27 Author string `json:"author"`
28 Version string `json:"version"`
29 ID string `json:"id"`
30 Event string `json:"event"`
31 Query string `json:"q"`
32 Cursor string `json:"cursor"`
33 Limit int `json:"limit"`
34}
35
36type wikiPageItem struct {
37 D string `json:"d"`
38 Title string `json:"title"`
39 Summary string `json:"summary,omitempty"`
40 Version wikiVersion `json:"version"`
41 Versions int `json:"versions"`
42 OpenMerges int `json:"open_merges"`
43}
44
45type wikiPage struct {
46 D string `json:"d"`
47 Title string `json:"title"`
48 Version *wikiVersion `json:"version"`
49 PreferredBy string `json:"preferred_by,omitempty"`
50 Versions []wikiVersion `json:"versions"`
51 Merges []wikiMerge `json:"merges"`
52 RedirectsTo []wikiRedirect `json:"redirects_to"`
53 RedirectsFrom []wikiRedirect `json:"redirects_from"`
54 // History lists every revision of the page the caller may see across
55 // authors, newest first: each author's current version and the
56 // archived revisions it replaced.
57 History []wikiVersion `json:"history"`
58 // CanApprove reports whether the caller may accept or reject proposals.
59 CanApprove bool `json:"can_approve"`
60}
61
62// wikiSet is what an actor may see of the pages a query matched. Versions
63// holds one version per author and page, with a hidden proposal replaced
64// by that author's newest approved revision; History holds the visible
65// current versions and archived revisions; archived keeps the archived
66// events by id for reading their content.
67type wikiSet struct {
68 Versions []wikiVersion
69 History []wikiVersion
70 archived map[string]event.Event
71}
72
73type wikiMergeDetail struct {
74 Merge wikiMerge `json:"merge"`
75 Proposed *wikiVersion `json:"proposed"`
76 Target *wikiVersion `json:"target"`
77}
78
79func wikiBrowseMethod(method string) bool {
80 return method == "browsewiki" || method == "browsewikipage" || method == "browsewikimerge"
81}
82
83func (t *Tenant) executeWiki(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
84 q := wikiBrowseRequest{}
85 if len(params) > 0 {
86 if err := json.Unmarshal(params[0], &q); err != nil {
87 return nil, fmt.Errorf("invalid: wiki parameters: %w", err)
88 }
89 }
90 if q.Limit <= 0 || q.Limit > 100 {
91 q.Limit = 50
92 }
93 if q.Author != "" && len(q.Author) != 64 {
94 return nil, errors.New("invalid: wiki author")
95 }
96 switch method {
97 case "browsewiki":
98 return t.browseWiki(ctx, actor, q)
99 case "browsewikipage":
100 return t.browseWikiPage(ctx, actor, q)
101 case "browsewikimerge":
102 if q.Event == "" {
103 q.Event = q.ID
104 }
105 return t.browseWikiMerge(ctx, actor, q)
106 }
107 return nil, errors.New("unsupported: browse operation")
108}
109
110// wikiQuery reads visible events for the caller's session.
111func (t *Tenant) wikiQuery(ctx context.Context, session relay.Session, filters ...event.Filter) ([]event.Event, error) {
112 if _, err := t.gate.Read(ctx, filters, session); err != nil {
113 return nil, err
114 }
115 var rows []event.Event
116 for _, filter := range filters {
117 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: wikiQueryLimit})
118 if err != nil {
119 return nil, err
120 }
121 for _, row := range page.Events {
122 if t.gate.CanSee(ctx, row, session, nil) {
123 rows = append(rows, row)
124 }
125 }
126 }
127 rows = uniqueEvents(rows)
128 sortEventsNewestFirst(rows)
129 return rows, nil
130}
131
132// wikiVersions reads the versions and archived revisions the filter
133// matches, with each proposal's state filled in and every revision
134// numbered, and keeps what the actor may see. A pending or rejected
135// proposal stays in place only for the owner, moderators and its author;
136// for anyone else the author's newest approved revision stands in for it,
137// and the author has no version when there is none.
138func (t *Tenant) wikiVersions(ctx context.Context, roles *wikiRoles, actor string, session relay.Session, filter event.Filter) (wikiSet, error) {
139 filter.Kinds = []int{kindWikiArticle}
140 rows, err := t.wikiQuery(ctx, session, filter)
141 if err != nil {
142 return wikiSet{}, err
143 }
144 current := make([]wikiVersion, 0, len(rows))
145 ids := make(map[string]bool, len(rows))
146 for _, row := range rows {
147 if event.Tag(row, "d") != "" {
148 current = append(current, wikiVersionFrom(row, false))
149 ids[row.ID] = true
150 }
151 }
152 d := ""
153 if names := filter.Tags["d"]; len(names) == 1 {
154 d = names[0]
155 }
156 revisions, err := t.store.WikiRevisions(ctx, "", d)
157 if err != nil {
158 return wikiSet{}, err
159 }
160 set := wikiSet{Versions: []wikiVersion{}, History: []wikiVersion{}, archived: map[string]event.Event{}}
161 archived := make([]wikiVersion, 0, len(revisions))
162 for _, r := range revisions {
163 if ids[r.ID] || event.Tag(r.Event, "d") == "" || !t.gate.CanSee(ctx, r.Event, session, nil) {
164 continue
165 }
166 v := wikiVersionFrom(r.Event, false)
167 v.SupersededBy = r.SupersededBy
168 archived = append(archived, v)
169 set.archived[v.ID] = r.Event
170 }
171 if err := t.wikiProposals(ctx, roles, current); err != nil {
172 return wikiSet{}, err
173 }
174 if err := t.wikiProposals(ctx, roles, archived); err != nil {
175 return wikiSet{}, err
176 }
177 wikiNumberRevisions(current, archived)
178 decides, err := roles.decides(ctx, actor)
179 if err != nil {
180 return wikiSet{}, err
181 }
182 for _, v := range current {
183 if wikiSees(decides, actor, v) {
184 set.Versions = append(set.Versions, v)
185 set.History = append(set.History, v)
186 } else if fallback, ok := wikiFallback(v, archived); ok {
187 set.Versions = append(set.Versions, fallback)
188 }
189 }
190 for _, v := range archived {
191 if wikiSees(decides, actor, v) {
192 set.History = append(set.History, v)
193 }
194 }
195 sortWikiVersions(set.Versions)
196 sortWikiVersions(set.History)
197 return set, nil
198}
199
200// wikiMerges lists the merge requests aimed at any of the coordinates with
201// their answers, newest first.
202func (t *Tenant) wikiMerges(ctx context.Context, session relay.Session, coordinates []string) ([]wikiMerge, error) {
203 if len(coordinates) == 0 {
204 return []wikiMerge{}, nil
205 }
206 rows, err := t.wikiQuery(ctx, session, event.Filter{Kinds: []int{kindWikiMerge}, Tags: map[string][]string{"a": coordinates}})
207 if err != nil {
208 return nil, err
209 }
210 merges := make([]wikiMerge, 0, len(rows))
211 destinations := make(map[string]string, len(rows))
212 for _, row := range rows {
213 m := wikiMergeFrom(row)
214 if m.Destination == "" {
215 continue
216 }
217 merges = append(merges, m)
218 destinations[m.ID] = m.Destination
219 }
220 answers, err := t.wikiMergeStatuses(ctx, destinations)
221 if err != nil {
222 return nil, err
223 }
224 for i := range merges {
225 merges[i].wikiAnswer = answers[merges[i].ID]
226 }
227 return merges, nil
228}
229
230func wikiMatches(v wikiVersion, query string) bool {
231 query = strings.ToLower(strings.TrimSpace(query))
232 return query == "" || strings.Contains(strings.ToLower(v.Title), query) || strings.Contains(strings.ToLower(v.Summary), query) || strings.Contains(v.D, query)
233}
234
235func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {
236 session := browseSession(t, actor)
237 roles := t.wikiRoles()
238 set, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{})
239 if err != nil {
240 return nil, err
241 }
242 decides, err := roles.decides(ctx, actor)
243 if err != nil {
244 return nil, err
245 }
246 pages := map[string][]wikiVersion{}
247 for _, v := range set.Versions {
248 if v.D <= q.Cursor || !wikiMatches(v, q.Query) {
249 continue
250 }
251 pages[v.D] = append(pages[v.D], v)
252 }
253 names := make([]string, 0, len(pages))
254 for d := range pages {
255 names = append(names, d)
256 }
257 sort.Strings(names)
258 next := ""
259 if len(names) > q.Limit {
260 names = names[:q.Limit]
261 next = names[len(names)-1]
262 }
263 owner := t.Policy().Owner
264 items := make([]wikiPageItem, 0, len(names))
265 for _, d := range names {
266 page := pages[d]
267 if err := t.wikiRankVersions(ctx, page, q.Author, owner); err != nil {
268 return nil, err
269 }
270 preferred, _ := wikiPreferred(page, q.Author, owner)
271 merges, err := t.wikiMerges(ctx, session, wikiCoordinates(page))
272 if err != nil {
273 return nil, err
274 }
275 open := 0
276 for _, m := range merges {
277 if m.Status == wikiMergeOpen {
278 open++
279 }
280 }
281 items = append(items, wikiPageItem{D: d, Title: preferred.Title, Summary: preferred.Summary, Version: preferred, Versions: len(page), OpenMerges: open})
282 }
283 return map[string]any{"items": items, "next_cursor": next, "can_approve": decides}, nil
284}
285
286// wikiRankVersions fills in member likes when they decide the preferred
287// version, which is only when neither an explicit author nor the owner has
288// one.
289func (t *Tenant) wikiRankVersions(ctx context.Context, versions []wikiVersion, author, owner string) error {
290 for _, v := range versions {
291 if v.Author == owner || (author != "" && v.Author == author) {
292 return nil
293 }
294 }
295 likes, err := t.wikiMemberLikes(ctx, versions)
296 if err != nil {
297 return err
298 }
299 for i := range versions {
300 versions[i].Likes = likes[versions[i].ID]
301 }
302 return nil
303}
304
305func wikiCoordinates(versions []wikiVersion) []string {
306 coordinates := make([]string, 0, len(versions))
307 for _, v := range versions {
308 coordinates = append(coordinates, v.Coordinate)
309 }
310 return coordinates
311}
312
313func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {
314 d := wiki.Normalize(q.D)
315 if d == "" {
316 return nil, errors.New("invalid: wiki page name")
317 }
318 session := browseSession(t, actor)
319 roles := t.wikiRoles()
320 set, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{Tags: map[string][]string{"d": {d}}})
321 if err != nil {
322 return nil, err
323 }
324 versions := set.Versions
325 decides, err := roles.decides(ctx, actor)
326 if err != nil {
327 return nil, err
328 }
329 redirects, err := t.wikiQuery(ctx, session, event.Filter{Kinds: []int{kindWikiRedirect}, Tags: map[string][]string{"d": {d}}}, event.Filter{Kinds: []int{kindWikiRedirect}, Tags: map[string][]string{"a": wikiCoordinates(versions)}})
330 if err != nil {
331 return nil, err
332 }
333 page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}, History: set.History, CanApprove: decides}
334 for _, row := range redirects {
335 r := wikiRedirectFrom(row)
336 if r.D == d && r.TargetD != "" && r.TargetD != d {
337 page.RedirectsFrom = append(page.RedirectsFrom, r)
338 } else if r.TargetD == d && r.D != d {
339 page.RedirectsTo = append(page.RedirectsTo, r)
340 }
341 }
342 if len(versions) == 0 && len(page.RedirectsFrom) == 0 {
343 return nil, errors.New("not found: wiki page")
344 }
345 if len(versions) == 0 {
346 return page, nil
347 }
348 likes, err := t.wikiMemberLikes(ctx, append(append([]wikiVersion{}, versions...), set.History...))
349 if err != nil {
350 return nil, err
351 }
352 for i := range versions {
353 versions[i].Likes = likes[versions[i].ID]
354 }
355 for i := range page.History {
356 page.History[i].Likes = likes[page.History[i].ID]
357 }
358 owner := t.Policy().Owner
359 preferred, by := wikiPreferred(versions, q.Author, owner)
360 if q.Version != "" {
361 // An id names a current version, or an archived revision from the
362 // history when it is no longer its author's current version.
363 by = "version"
364 found := false
365 for _, v := range append(append([]wikiVersion{}, versions...), page.History...) {
366 if v.ID == q.Version {
367 preferred, found = v, true
368 break
369 }
370 }
371 if !found {
372 return nil, errors.New("not found: wiki version")
373 }
374 }
375 if e, ok := set.archived[preferred.ID]; ok {
376 preferred = wikiFull(e, preferred)
377 } else {
378 rows, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{preferred.ID}, Limit: intPtr(1)})
379 if err != nil {
380 return nil, err
381 }
382 if len(rows) == 1 {
383 preferred = wikiFull(rows[0], preferred)
384 }
385 }
386 page.Title = preferred.Title
387 page.Version = &preferred
388 page.PreferredBy = by
389 page.Merges, err = t.wikiMerges(ctx, session, wikiCoordinates(versions))
390 if err != nil {
391 return nil, err
392 }
393 return page, nil
394}
395
396func (t *Tenant) browseWikiMerge(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {
397 if len(q.Event) != 64 {
398 return nil, errors.New("invalid: wiki merge request")
399 }
400 session := browseSession(t, actor)
401 rows, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{q.Event}, Kinds: []int{kindWikiMerge}, Limit: intPtr(1)})
402 if err != nil {
403 return nil, err
404 }
405 if len(rows) != 1 {
406 return nil, errors.New("not found: wiki merge request")
407 }
408 detail := wikiMergeDetail{Merge: wikiMergeFrom(rows[0])}
409 if detail.Merge.Destination == "" {
410 return nil, errors.New("not found: wiki merge request has no destination")
411 }
412 detail.Merge.wikiAnswer, err = t.wikiMergeStatus(ctx, detail.Merge.ID, detail.Merge.Destination)
413 if err != nil {
414 return nil, err
415 }
416 if detail.Merge.Source != "" {
417 proposed, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{detail.Merge.Source}, Kinds: []int{kindWikiArticle}, Limit: intPtr(1)})
418 if err != nil {
419 return nil, err
420 }
421 if len(proposed) == 1 {
422 v := wikiVersionFrom(proposed[0], true)
423 detail.Proposed = &v
424 } else if r, ok, err := t.store.WikiRevision(ctx, detail.Merge.Source); err != nil {
425 return nil, err
426 } else if ok && t.gate.CanSee(ctx, r.Event, session, nil) {
427 // The proposed version was replaced since the request was
428 // made; the archived revision still shows what was asked.
429 v := wikiVersionFrom(r.Event, true)
430 v.SupersededBy = r.SupersededBy
431 detail.Proposed = &v
432 }
433 }
434 if detail.Merge.TargetD != "" {
435 target, err := t.wikiQuery(ctx, session, event.Filter{Kinds: []int{kindWikiArticle}, Authors: []string{detail.Merge.Destination}, Tags: map[string][]string{"d": {detail.Merge.TargetD}}, Limit: intPtr(1)})
436 if err != nil {
437 return nil, err
438 }
439 if len(target) == 1 {
440 v := wikiVersionFrom(target[0], true)
441 detail.Target = &v
442 }
443 }
444 // The versions keep their proposal state; a merge request's reader
445 // must see the proposed content to answer it, so nothing is hidden.
446 roles := t.wikiRoles()
447 for _, v := range []*wikiVersion{detail.Proposed, detail.Target} {
448 if v == nil {
449 continue
450 }
451 marked := []wikiVersion{*v}
452 if err := t.wikiProposals(ctx, roles, marked); err != nil {
453 return nil, err
454 }
455 *v = marked[0]
456 }
457 return detail, nil
458}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs