clone

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

feat/ui-updates
tinyrelay / internal / daemon / collaboration_browse.go
collaboration_browse.go 14.6K
1package daemon23import (4 "context"5 "encoding/json"6 "errors"7 "sort"8 "strconv"9 "strings"10 "time"1112 "github.com/FelineStateMachine/tinyrelay/internal/event"13 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"14 "github.com/FelineStateMachine/tinyrelay/internal/relay"15 "github.com/FelineStateMachine/tinyrelay/internal/storage"16)1718type collaborationItem struct {19 ID string `json:"id"`20 Kind int `json:"kind"`21 Author string `json:"author"`22 CreatedAt int64 `json:"created_at"`23 Title string `json:"title"`24 Content string `json:"content"`25 Labels []string `json:"labels"`26 Status string `json:"status"`27 collaborationProposal28}2930type collaborationDetail struct {31 Repository map[string]any `json:"repository"`32 Root event.Event `json:"root"`33 Item collaborationItem `json:"item"`34 Replies []collaborationReply `json:"replies"`35 Updates []event.Event `json:"updates"`36 Statuses []event.Event `json:"statuses"`37 Diff string `json:"diff,omitempty"`38 CanStatus bool `json:"can_status"`39 CanApprove bool `json:"can_approve"`40 DiffUnavailable string `json:"diff_unavailable,omitempty"`41 DiffTruncated bool `json:"diff_truncated,omitempty"`42 ReplyCursor string `json:"reply_cursor,omitempty"`43}4445func (t *Tenant) collaborationRepository(ctx context.Context, repo gitrelay.Repository) map[string]any {46 return map[string]any{"owner": repo.Owner, "private": repo.Private, "clone": repo.Clone, "refs": repo.Refs, "head": repo.Head, "maintainers": t.repositoryMaintainers(ctx, repo)}47}4849func (t *Tenant) collaborationRepo(ctx context.Context, actor string, q clientBrowseRequest) (gitrelay.Repository, error) {50 if t.git == nil || !t.Policy().Features.Grasp {51 return gitrelay.Repository{}, errors.New("not found: Git hosting is disabled")52 }53 r, err := t.git.BrowseRepository(q.Owner, q.Repo)54 if err != nil {55 return gitrelay.Repository{}, err56 }57 if err := t.browseRepoAccess(ctx, actor, r); err != nil {58 return gitrelay.Repository{}, err59 }60 r.Private = r.Private || t.PrivateServiceEnabled()61 return r, nil62}6364func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q clientBrowseRequest, kind int) (any, error) {65 r, err := t.collaborationRepo(ctx, actor, q)66 if err != nil {67 return nil, err68 }69 if q.Limit <= 0 || q.Limit > 100 {70 q.Limit = 5071 }72 cursor, err := parseCollaborationCursor(q.Cursor)73 if err != nil {74 return nil, err75 }76 if q.State != "" && !contains([]string{"open", "resolved", "merged", "closed", "draft"}, q.State) {77 return nil, errors.New("invalid: issue or pull request status")78 }79 coordinate := "30617:" + r.Owner + ":" + r.Identifier80 filters := []event.Filter{{Kinds: []int{kind}, Tags: map[string][]string{"a": {coordinate}}}, {Kinds: []int{kind}, Tags: map[string][]string{"A": {coordinate}}}}81 session := browseSession(t, actor)82 if _, err := t.gate.Read(ctx, filters, session); err != nil {83 return nil, err84 }85 roles := t.collaborationRoles(r)86 canApprove, err := roles.decides(ctx, actor)87 if err != nil {88 return nil, err89 }90 result := func(items []collaborationItem, next string) map[string]any {91 return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next, "can_approve": canApprove}92 }93 items := make([]collaborationItem, 0, q.Limit)94 next := ""95 for scan := 0; scan < 100; scan++ {96 var rows []event.Event97 more := false98 for _, filter := range filters {99 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 100, Before: cursor})100 if err != nil {101 return nil, err102 }103 more = more || page.More104 rows = append(rows, page.Events...)105 }106 rows = uniqueEvents(rows)107 sortEventsNewestFirst(rows)108 if len(rows) > 100 {109 rows = rows[:100]110 more = true111 }112 matching := make([]event.Event, 0, len(rows))113 for _, root := range rows {114 if t.gate.CanSee(ctx, root, session, nil) && collaborationMatches(root, q.Query) && matchesCollaborationLabel(root, q.Label) {115 matching = append(matching, root)116 }117 }118 visible, states, err := t.collaborationVisible(ctx, roles, actor, matching)119 if err != nil {120 return nil, err121 }122 proposals := make(map[string]collaborationProposal, len(visible))123 for i, root := range visible {124 proposals[root.ID] = states[i]125 }126 statuses, err := t.collaborationStatuses(ctx, actor, roles, visible)127 if err != nil {128 return nil, err129 }130 for _, root := range rows {131 cursor = &storage.EventCursor{CreatedAt: root.CreatedAt, ID: root.ID}132 status, ok := statuses[root.ID]133 if !ok || (q.State != "" && q.State != status) {134 continue135 }136 if len(items) == q.Limit {137 return result(items, next), nil138 }139 item := collaborationItemFrom(root)140 item.Status = status141 item.collaborationProposal = proposals[root.ID]142 items = append(items, item)143 next = collaborationCursor(item)144 }145 if !more {146 return result(items, ""), nil147 }148 }149 if cursor != nil {150 next = strconv.FormatInt(cursor.CreatedAt, 10) + ":" + cursor.ID151 }152 return result(items, next), nil153}154155func parseCollaborationCursor(raw string) (*storage.EventCursor, error) {156 if raw == "" {157 return nil, nil158 }159 stamp, id, ok := strings.Cut(raw, ":")160 ts, err := strconv.ParseInt(stamp, 10, 64)161 if !ok || err != nil || ts < 0 || len(id) != 64 || strings.Trim(id, "0123456789abcdef") != "" {162 return nil, errors.New("invalid: collaboration cursor")163 }164 return &storage.EventCursor{CreatedAt: ts, ID: id}, nil165}166167func matchesCollaborationLabel(root event.Event, label string) bool {168 if label == "" {169 return true170 }171 for _, value := range event.TagValues(root, "t") {172 if strings.EqualFold(value, label) {173 return true174 }175 }176 return false177}178179func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q clientBrowseRequest, kind int) (any, error) {180 if len(q.Event) != 64 {181 return nil, errors.New("invalid: collaboration event")182 }183 r, err := t.collaborationRepo(ctx, actor, q)184 if err != nil {185 return nil, err186 }187 rows, err := t.Query(ctx, []event.Filter{{IDs: []string{q.Event}, Limit: intPtr(1)}}, browseSession(t, actor))188 if err != nil {189 return nil, err190 }191 if len(rows) != 1 || rows[0].Kind != kind || !hasCoordinate(rows[0], r) {192 return nil, errors.New("not found: collaboration event")193 }194 roles := t.collaborationRoles(r)195 roots, states, err := t.collaborationVisible(ctx, roles, actor, rows[:1])196 if err != nil {197 return nil, err198 }199 if len(roots) != 1 {200 return nil, errors.New("not found: collaboration event")201 }202 root := roots[0]203 detail := collaborationDetail{Repository: t.collaborationRepository(ctx, r), Root: root, Item: collaborationItemFrom(root), CanStatus: actor != "" && (actor == root.PubKey || t.IsMaintainer(ctx, r, actor))}204 detail.Item.collaborationProposal = states[0]205 if detail.CanApprove, err = roles.decides(ctx, actor); err != nil {206 return nil, err207 }208 replyCursor, err := parseCollaborationCursor(q.Cursor)209 if err != nil {210 return nil, err211 }212 replyLimit := q.Limit213 if replyLimit <= 0 || replyLimit > 200 {214 replyLimit = 200215 }216 authors := append([]string{root.PubKey}, t.maintainerKeys(ctx, r)...)217 statuses, err := t.Query(ctx, []event.Filter{{Authors: authors, Kinds: []int{1630, 1631, 1632, 1633}, Tags: map[string][]string{"e": {root.ID}}, Limit: intPtr(100)}}, browseSession(t, actor))218 if err != nil {219 return nil, err220 }221 if statuses, err = t.collaborationDecided(ctx, roles, statuses); err != nil {222 return nil, err223 }224 detail.Statuses = statuses225 detail.Item.Status = statusFromEvents(root, statuses)226 replyFilters := []event.Filter{227 {Kinds: []int{1111}, Tags: map[string][]string{"E": {root.ID}, "K": {strconv.Itoa(root.Kind)}, "P": {root.PubKey}}},228 }229 // Pending replies are dropped after the page is cut, so a hidden230 // proposal never shifts the cursor for the people who cannot see it.231 replies, err := t.queryCollaborationThread(ctx, actor, replyFilters, replyCursor, replyLimit+1)232 if err != nil {233 return nil, err234 }235 if len(replies) > replyLimit {236 replies = replies[:replyLimit]237 detail.ReplyCursor = collaborationCursor(collaborationItemFrom(replies[replyLimit-1]))238 }239 replies, replyStates, err := t.collaborationVisible(ctx, roles, actor, replies)240 if err != nil {241 return nil, err242 }243 detail.Replies = collaborationReplies(replies, replyStates)244 if kind == event.KIND_GIT_PR {245 detail.Updates, err = t.Query(ctx, []event.Filter{{Authors: []string{root.PubKey}, Kinds: []int{1619}, Tags: map[string][]string{"E": {root.ID}, "P": {root.PubKey}}, Limit: intPtr(100)}}, browseSession(t, actor))246 if err != nil {247 return nil, err248 }249 tipEvent := root250 valid := make([]event.Event, 0, len(detail.Updates))251 for _, update := range detail.Updates {252 if event.Tag(update, "P") == root.PubKey && event.Tag(update, "E") == root.ID {253 valid = append(valid, update)254 }255 }256 detail.Updates = valid257 if len(valid) > 0 {258 tipEvent = valid[0]259 }260 diff := t.git.PRDiff(ctx, r, event.Tag(tipEvent, "merge-base"), event.Tag(tipEvent, "c"))261 if diff.Status == "ok" {262 detail.Diff = diff.Diff263 detail.DiffTruncated = diff.Truncated264 } else {265 detail.DiffUnavailable = diff.Reason266 }267 }268269 return detail, nil270}271272func collaborationItemFrom(e event.Event) collaborationItem {273 return collaborationItem{ID: e.ID, Kind: e.Kind, Author: e.PubKey, CreatedAt: e.CreatedAt, Title: collaborationTitle(e), Content: e.Content, Labels: event.TagValues(e, "t")}274}275276func collaborationTitle(e event.Event) string {277 for _, key := range []string{"subject", "title", "name"} {278 if value := event.Tag(e, key); value != "" {279 return value280 }281 }282 content := strings.TrimSpace(e.Content)283 var object struct {284 Title string `json:"title"`285 Subject string `json:"subject"`286 }287 if json.Unmarshal([]byte(content), &object) == nil {288 if object.Title != "" {289 return object.Title290 }291 if object.Subject != "" {292 return object.Subject293 }294 }295 if line, _, ok := strings.Cut(content, "\n"); ok {296 return line297 }298 return content299}300301func collaborationMatches(e event.Event, query string) bool {302 query = strings.ToLower(strings.TrimSpace(query))303 return query == "" || strings.Contains(strings.ToLower(collaborationTitle(e)), query) || strings.Contains(strings.ToLower(e.Content), query) || collaborationLabels(e, query)304}305func collaborationLabels(e event.Event, query string) bool {306 for _, label := range event.TagValues(e, "t") {307 if strings.Contains(strings.ToLower(label), query) {308 return true309 }310 }311 return false312}313314// collaborationStatuses resolves the status of every root on a list page with315// one query. Only the root author, the owner and current maintainers can set a316// status, a proposer never can, and the newest authorized event wins.317func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roles *collaborationRoles, roots []event.Event) (map[string]string, error) {318 statuses := make(map[string]string, len(roots))319 if len(roots) == 0 {320 return statuses, nil321 }322 ids := make([]string, 0, len(roots))323 maintainers := t.maintainerKeys(ctx, roles.repo)324 authors := append([]string(nil), maintainers...)325 for _, root := range roots {326 ids = append(ids, root.ID)327 authors = append(authors, root.PubKey)328 }329 limit := 1000330 rows, err := t.Query(ctx, []event.Filter{{Authors: uniqueStrings(authors), Kinds: []int{1630, 1631, 1632, 1633}, Tags: map[string][]string{"e": ids}, Limit: &limit}}, browseSession(t, actor))331 if err != nil {332 return nil, err333 }334 if rows, err = t.collaborationDecided(ctx, roles, rows); err != nil {335 return nil, err336 }337 byRoot := make(map[string][]event.Event, len(roots))338 for _, row := range rows {339 for _, id := range event.TagValues(row, "e") {340 byRoot[id] = append(byRoot[id], row)341 }342 }343 for _, root := range roots {344 allowed := []event.Event{}345 for _, row := range byRoot[root.ID] {346 if row.PubKey == root.PubKey || contains(maintainers, row.PubKey) {347 allowed = append(allowed, row)348 }349 }350 statuses[root.ID] = statusFromEvents(root, allowed)351 }352 return statuses, nil353}354355func (t *Tenant) queryCollaborationThread(ctx context.Context, actor string, filters []event.Filter, cursor *storage.EventCursor, limit int) ([]event.Event, error) {356 session := browseSession(t, actor)357 if _, err := t.gate.Read(ctx, filters, session); err != nil {358 return nil, err359 }360 rows := make([]event.Event, 0)361 for _, filter := range filters {362 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: limit, Before: cursor})363 if err != nil {364 return nil, err365 }366 for _, row := range page.Events {367 if t.gate.CanSee(ctx, row, session, nil) {368 rows = append(rows, row)369 }370 }371 }372 rows = uniqueEvents(rows)373 sortEventsNewestFirst(rows)374 if len(rows) > limit {375 rows = rows[:limit]376 }377 return rows, nil378}379380func statusFromEvents(root event.Event, rows []event.Event) string {381 sort.Slice(rows, func(i, j int) bool {382 if rows[i].CreatedAt == rows[j].CreatedAt {383 return rows[i].ID < rows[j].ID384 }385 return rows[i].CreatedAt > rows[j].CreatedAt386 })387 if len(rows) == 0 {388 return "open"389 }390 statuses := map[int]string{1630: "open", 1632: "closed", 1633: "draft"}391 if root.Kind == event.KIND_GIT_ISSUE {392 statuses[1631] = "resolved"393 } else {394 statuses[1631] = "merged"395 }396 return statuses[rows[0].Kind]397}398399func collaborationCursor(item collaborationItem) string {400 return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID401}402403func afterCollaborationCursor(item event.Event, cursor string) bool {404 if cursor == "" {405 return true406 }407 stamp, id, ok := strings.Cut(cursor, ":")408 if !ok {409 return item.ID < cursor410 }411 timestamp, err := strconv.ParseInt(stamp, 10, 64)412 if err != nil {413 return false414 }415 return item.CreatedAt < timestamp || (item.CreatedAt == timestamp && item.ID > id)416}417418func hasCoordinate(e event.Event, r gitrelay.Repository) bool {419 coordinate := "30617:" + r.Owner + ":" + r.Identifier420 for _, tag := range e.Tags {421 if len(tag) > 1 && (tag[0] == "a" || tag[0] == "A") && tag[1] == coordinate {422 return true423 }424 }425 return false426}427func browseSession(t *Tenant, actor string) relay.Session {428 s := relay.Session{RelayURL: t.RelayURL()}429 if actor != "" {430 s.PubKeys = []string{actor}431 }432 return s433}434func contains(values []string, needle string) bool {435 for _, value := range values {436 if value == needle {437 return true438 }439 }440 return false441}442443func uniqueStrings(values []string) []string {444 seen := make(map[string]struct{}, len(values))445 result := make([]string, 0, len(values))446 for _, value := range values {447 if _, ok := seen[value]; ok {448 continue449 }450 seen[value] = struct{}{}451 result = append(result, value)452 }453 return result454}