clone

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

main
tinyrelay / internal / daemon / wiki_browse.go
wiki_browse.go 14.6K
1package daemon23// Wiki browsing: the page list, one page with its versions, the history4// of its revisions, merge requests and redirects, and one merge request.5// A version's earlier revisions come from the store's wiki_revisions6// table, where a replaced kind 30818 event is kept. Every read passes the tenant gate7// with the caller's session, so a members-only relay shows nothing to8// guests, and each event is checked with CanSee before it is returned.910import (11 "context"12 "encoding/json"13 "errors"14 "fmt"15 "sort"16 "strings"17 "time"1819 "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)2425type 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}3536type 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}4445type 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 across55 // authors, newest first: each author's current version and the56 // 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}6162// wikiSet is what an actor may see of the pages a query matched. Versions63// holds one version per author and page, with a hidden proposal replaced64// by that author's newest approved revision; History holds the visible65// current versions and archived revisions; archived keeps the archived66// events by id for reading their content.67type wikiSet struct {68 Versions []wikiVersion69 History []wikiVersion70 archived map[string]event.Event71}7273type wikiMergeDetail struct {74 Merge wikiMerge `json:"merge"`75 Proposed *wikiVersion `json:"proposed"`76 Target *wikiVersion `json:"target"`77}7879func wikiBrowseMethod(method string) bool {80 return method == "browsewiki" || method == "browsewikipage" || method == "browsewikimerge"81}8283func (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 = 5092 }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.ID104 }105 return t.browseWikiMerge(ctx, actor, q)106 }107 return nil, errors.New("unsupported: browse operation")108}109110// 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, err114 }115 var rows []event.Event116 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, err120 }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, nil130}131132// wikiVersions reads the versions and archived revisions the filter133// matches, with each proposal's state filled in and every revision134// numbered, and keeps what the actor may see. A pending or rejected135// 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{}, err143 }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] = true150 }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{}, err159 }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 continue165 }166 v := wikiVersionFrom(r.Event, false)167 v.SupersededBy = r.SupersededBy168 archived = append(archived, v)169 set.archived[v.ID] = r.Event170 }171 if err := t.wikiProposals(ctx, roles, current); err != nil {172 return wikiSet{}, err173 }174 if err := t.wikiProposals(ctx, roles, archived); err != nil {175 return wikiSet{}, err176 }177 wikiNumberRevisions(current, archived)178 decides, err := roles.decides(ctx, actor)179 if err != nil {180 return wikiSet{}, err181 }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, nil198}199200// wikiMerges lists the merge requests aimed at any of the coordinates with201// 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{}, nil205 }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, err209 }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 continue216 }217 merges = append(merges, m)218 destinations[m.ID] = m.Destination219 }220 answers, err := t.wikiMergeStatuses(ctx, destinations)221 if err != nil {222 return nil, err223 }224 for i := range merges {225 merges[i].wikiAnswer = answers[merges[i].ID]226 }227 return merges, nil228}229230func 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}234235func (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, err241 }242 decides, err := roles.decides(ctx, actor)243 if err != nil {244 return nil, err245 }246 pages := map[string][]wikiVersion{}247 for _, v := range set.Versions {248 if v.D <= q.Cursor || !wikiMatches(v, q.Query) {249 continue250 }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().Owner264 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, err269 }270 preferred, _ := wikiPreferred(page, q.Author, owner)271 merges, err := t.wikiMerges(ctx, session, wikiCoordinates(page))272 if err != nil {273 return nil, err274 }275 open := 0276 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}, nil284}285286// wikiRankVersions fills in member likes when they decide the preferred287// version, which is only when neither an explicit author nor the owner has288// 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 nil293 }294 }295 likes, err := t.wikiMemberLikes(ctx, versions)296 if err != nil {297 return err298 }299 for i := range versions {300 versions[i].Likes = likes[versions[i].ID]301 }302 return nil303}304305func 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 coordinates311}312313func (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, err323 }324 versions := set.Versions325 decides, err := roles.decides(ctx, actor)326 if err != nil {327 return nil, err328 }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, err332 }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, nil347 }348 likes, err := t.wikiMemberLikes(ctx, append(append([]wikiVersion{}, versions...), set.History...))349 if err != nil {350 return nil, err351 }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().Owner359 preferred, by := wikiPreferred(versions, q.Author, owner)360 if q.Version != "" {361 // An id names a current version, or an archived revision from the362 // history when it is no longer its author's current version.363 by = "version"364 found := false365 for _, v := range append(append([]wikiVersion{}, versions...), page.History...) {366 if v.ID == q.Version {367 preferred, found = v, true368 break369 }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, err381 }382 if len(rows) == 1 {383 preferred = wikiFull(rows[0], preferred)384 }385 }386 page.Title = preferred.Title387 page.Version = &preferred388 page.PreferredBy = by389 page.Merges, err = t.wikiMerges(ctx, session, wikiCoordinates(versions))390 if err != nil {391 return nil, err392 }393 return page, nil394}395396func (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, err404 }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, err415 }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, err420 }421 if len(proposed) == 1 {422 v := wikiVersionFrom(proposed[0], true)423 detail.Proposed = &v424 } else if r, ok, err := t.store.WikiRevision(ctx, detail.Merge.Source); err != nil {425 return nil, err426 } else if ok && t.gate.CanSee(ctx, r.Event, session, nil) {427 // The proposed version was replaced since the request was428 // made; the archived revision still shows what was asked.429 v := wikiVersionFrom(r.Event, true)430 v.SupersededBy = r.SupersededBy431 detail.Proposed = &v432 }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, err438 }439 if len(target) == 1 {440 v := wikiVersionFrom(target[0], true)441 detail.Target = &v442 }443 }444 // The versions keep their proposal state; a merge request's reader445 // 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 continue450 }451 marked := []wikiVersion{*v}452 if err := t.wikiProposals(ctx, roles, marked); err != nil {453 return nil, err454 }455 *v = marked[0]456 }457 return detail, nil458}