tinyrelayfile
tinyrelay/internal/daemon/client_browse.go/

internal/daemon/client_browse.go raw
1package daemon
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "sort"
11 "strings"
12 "time"
13 "unicode/utf8"
14
15 "github.com/FelineStateMachine/tinyrelay/internal/blob"
16 "github.com/FelineStateMachine/tinyrelay/internal/community"
17 "github.com/FelineStateMachine/tinyrelay/internal/event"
18 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
19 "github.com/FelineStateMachine/tinyrelay/internal/relay"
20 "github.com/FelineStateMachine/tinyrelay/internal/storage"
21)
22
23type clientBrowseRequest struct {
24 gitrelay.BrowseRequest
25 Cursor string `json:"cursor"`
26 Query string `json:"q"`
27 Hash string `json:"hash"`
28 Event string `json:"event"`
29 ID string `json:"id"`
30 State string `json:"state"`
31 Label string `json:"label"`
32 Agent string `json:"agent"`
33 Pubkey string `json:"pubkey"`
34}
35
36func clientBrowseMethod(method string) bool {
37 return method == "browsechatactivity" || wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || directMessagesBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method)
38}
39
40func (t *Tenant) browseRead(ctx context.Context, actor string) error {
41 keys := []string{}
42 if actor != "" {
43 keys = append(keys, actor)
44 }
45 _, err := t.gate.Read(ctx, []event.Filter{{}}, relay.Session{PubKeys: keys, RelayURL: t.RelayURL()})
46 return err
47}
48
49func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params []json.RawMessage) (result any, err error) {
50 ctx, finish := t.app.telemetry.Start(ctx, method)
51 defer func() {
52 if err != nil {
53 finish("error")
54 } else {
55 finish("success")
56 }
57 }()
58 if err := t.browseRead(ctx, actor); err != nil {
59 return nil, err
60 }
61 if method == "browsechatactivity" {
62 return t.browseChatActivity(ctx, actor, params)
63 }
64 if wikiBrowseMethod(method) {
65 return t.executeWiki(ctx, actor, method, params)
66 }
67 if approvalBrowseMethod(method) {
68 return t.executeApprovals(ctx, actor, method, params)
69 }
70 if jobBrowseMethod(method) {
71 return t.executeJobs(ctx, actor, method, params)
72 }
73 if socialBrowseMethod(method) {
74 return t.executeSocial(ctx, actor, method, params)
75 }
76 if directMessagesBrowseMethod(method) {
77 return t.executeDirectMessages(ctx, actor, params)
78 }
79 q := clientBrowseRequest{}
80 if len(params) > 0 {
81 if err := json.Unmarshal(params[0], &q); err != nil {
82 return nil, fmt.Errorf("invalid: browse parameters: %w", err)
83 }
84 }
85 if q.Limit <= 0 || q.Limit > 100 {
86 q.Limit = 50
87 }
88 switch method {
89 case "browserepos":
90 return t.browseRepos(ctx, actor, q)
91 case "browserepo":
92 return t.browseRepo(ctx, actor, q)
93 case "browsefiles":
94 return t.browseFiles(ctx, actor, q)
95 case "browsefile":
96 return t.browseFile(ctx, actor, q.Hash)
97 case "browsestatus":
98 return t.browseStatus(ctx, actor)
99 case "browseagent":
100 return t.browseAgent(ctx, actor, q.Agent)
101 case "browseprofile":
102 return t.browseProfile(ctx, actor, q.Pubkey)
103 case "browseissues":
104 return t.browseCollaboration(ctx, actor, q, event.KIND_GIT_ISSUE)
105 case "browsepulls":
106 return t.browseCollaboration(ctx, actor, q, event.KIND_GIT_PR)
107 case "browseissue":
108 if q.Event == "" {
109 q.Event = q.ID
110 }
111 return t.browseCollaborationDetail(ctx, actor, q, event.KIND_GIT_ISSUE)
112 case "browsepull":
113 if q.Event == "" {
114 q.Event = q.ID
115 }
116 return t.browseCollaborationDetail(ctx, actor, q, event.KIND_GIT_PR)
117 default:
118 return nil, errors.New("unsupported: browse operation")
119 }
120}
121
122func (t *Tenant) browseRepoAccess(ctx context.Context, actor string, r gitrelay.Repository) error {
123 if !t.Policy().Features.Grasp || t.git == nil {
124 return errors.New("not found: Git hosting is disabled")
125 }
126 if !r.Private {
127 return nil
128 }
129 if actor == "" {
130 return errors.New("auth-required: private repository")
131 }
132 role, err := t.community.Role(ctx, actor)
133 if err != nil {
134 return err
135 }
136 if role == "owner" || role == "moderator" || role == "member" || t.IsMaintainer(ctx, r, actor) {
137 return nil
138 }
139 return errors.New("restricted: private repository membership required")
140}
141
142func (t *Tenant) browseRepos(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
143 if !t.Policy().Features.Grasp || t.git == nil {
144 return nil, errors.New("not found: Git hosting is disabled")
145 }
146 items := []map[string]any{}
147 next := ""
148 for _, r := range t.git.Repositories() {
149 cursor := r.Owner + "/" + r.Identifier
150 if cursor <= q.Cursor || (q.Query != "" && !strings.Contains(strings.ToLower(r.Identifier), strings.ToLower(q.Query))) {
151 continue
152 }
153 if err := t.browseRepoAccess(ctx, actor, r); err != nil {
154 continue
155 }
156 if len(items) == q.Limit {
157 last := items[len(items)-1]
158 next = last["owner"].(string) + "/" + last["identifier"].(string)
159 break
160 }
161 items = append(items, map[string]any{"owner": r.Owner, "identifier": r.Identifier, "head": r.Head, "private": r.Private || t.PrivateServiceEnabled(), "clone": r.Clone, "relays": r.Relays, "refs": r.Refs})
162 }
163 return map[string]any{"items": items, "next_cursor": next}, nil
164}
165
166func (t *Tenant) browseRepo(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
167 if t.git == nil {
168 return nil, errors.New("not found: Git hosting")
169 }
170 r, err := t.git.BrowseRepository(q.Owner, q.Repo)
171 if err != nil {
172 return nil, err
173 }
174 if err := t.browseRepoAccess(ctx, actor, r); err != nil {
175 return nil, err
176 }
177 page, err := t.git.Browse(ctx, q.BrowseRequest)
178 if err != nil {
179 return nil, err
180 }
181 page.Private = page.Private || t.PrivateServiceEnabled()
182 page.Maintainers = t.repositoryMaintainers(ctx, r)
183 if q.View != "activity" {
184 return page, nil
185 }
186 rows, err := t.browseRepoEvents(ctx, actor, r, q.Limit)
187 return struct {
188 gitrelay.BrowsePage
189 Events []event.Event `json:"events"`
190 }{page, rows}, err
191}
192
193func (t *Tenant) browseRepoEvents(ctx context.Context, actor string, r gitrelay.Repository, limit int) ([]event.Event, error) {
194 coordinate := "30617:" + r.Owner + ":" + r.Identifier
195 kinds := []int{1617, 1618, 1619, 1621, 1111, 1630, 1631, 1632, 1633}
196 filters := []event.Filter{}
197 for _, tag := range []string{"a", "A"} {
198 filters = append(filters, event.Filter{Kinds: kinds, Tags: map[string][]string{tag: {coordinate}}, Limit: &limit})
199 }
200 session := relay.Session{RelayURL: t.RelayURL()}
201 if actor != "" {
202 session.PubKeys = []string{actor}
203 }
204 rows, err := t.Query(ctx, filters, session)
205 if err != nil {
206 return nil, err
207 }
208 ids := []string{}
209 for _, row := range rows {
210 ids = append(ids, row.ID)
211 }
212 if len(ids) > 0 {
213 threads := []event.Filter{}
214 for _, tag := range []string{"e", "E"} {
215 threads = append(threads, event.Filter{Kinds: kinds, Tags: map[string][]string{tag: ids}, Limit: &limit})
216 }
217 replies, err := t.Query(ctx, threads, session)
218 if err != nil {
219 return nil, err
220 }
221 rows = append(rows, replies...)
222 }
223 // Pending and rejected proposals from agents are left out for everyone
224 // but the deciders and their author.
225 if rows, _, err = t.collaborationVisible(ctx, t.collaborationRoles(r), actor, rows); err != nil {
226 return nil, err
227 }
228 sort.Slice(rows, func(i, j int) bool {
229 if rows[i].CreatedAt == rows[j].CreatedAt {
230 return rows[i].ID < rows[j].ID
231 }
232 return rows[i].CreatedAt > rows[j].CreatedAt
233 })
234 seen := map[string]bool{}
235 result := []event.Event{}
236 for _, row := range rows {
237 if !seen[row.ID] {
238 seen[row.ID] = true
239 result = append(result, row)
240 }
241 if len(result) == limit {
242 break
243 }
244 }
245 return result, nil
246}
247
248func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error) {
249 if !t.Policy().Features.Files {
250 return nil, errors.New("not found: files are disabled")
251 }
252 if err := t.fileReadAccess(ctx, actor, hash); err != nil {
253 return nil, err
254 }
255 if err := t.roomAttachmentAccess(ctx, actor, hash); err != nil {
256 return nil, err
257 }
258 entry, body, err := t.blobs.Get(ctx, hash)
259 if err != nil {
260 return nil, err
261 }
262 defer body.Close()
263 data, err := io.ReadAll(io.LimitReader(body, 256*1024+1))
264 if err != nil {
265 return nil, err
266 }
267 truncated := len(data) > 256*1024
268 if truncated {
269 data = data[:256*1024]
270 }
271 binary := bytes.IndexByte(data, 0) >= 0 || (!utf8.Valid(data) && !truncated)
272 result := t.browseBlobMetadata(entry)
273 if access, accessErr := t.blobs.BlobAccess(ctx, entry.SHA256); accessErr == nil {
274 result["access"] = access
275 }
276 if actor != "" {
277 var name, filePath string
278 metadataQuery := `SELECT name,path FROM blob_claim_metadata WHERE sha256=? AND uploader=? ORDER BY updated_at DESC,path LIMIT 1`
279 metadataArgs := []any{entry.SHA256, actor}
280 if access, accessErr := t.blobs.BlobAccess(ctx, entry.SHA256); accessErr == nil && access == blob.AccessMembers {
281 metadataQuery = `SELECT name,path FROM blob_claim_metadata WHERE sha256=? ORDER BY updated_at DESC,path LIMIT 1`
282 metadataArgs = []any{entry.SHA256}
283 }
284 if err := t.store.DB().QueryRowContext(ctx, metadataQuery, metadataArgs...).Scan(&name, &filePath); err == nil {
285 if name != "" {
286 result["name"] = name
287 }
288 if filePath != "" {
289 result["path"] = filePath
290 }
291 }
292 }
293 result["binary"], result["truncated"] = binary, truncated
294 if entry.Type == "application/octet-stream" {
295 if detected := blob.DetectMediaType(data); detected != "application/octet-stream" {
296 result["type"] = detected
297 }
298 }
299 if actor == "" {
300 delete(result, "uploader")
301 }
302 if !binary {
303 result["content"] = strings.ToValidUTF8(string(data), "�")
304 }
305 return result, nil
306}
307
308// browseAgent returns one agent grant with the agent's newest events for the
309// Manage > Agents page. Like listagents, it is for the owner and moderators.
310func (t *Tenant) browseAgent(ctx context.Context, actor, agent string) (any, error) {
311 role, err := t.community.Role(ctx, actor)
312 if err != nil {
313 return nil, err
314 }
315 if role != "owner" && role != "moderator" {
316 return nil, errors.New("restricted: agent details")
317 }
318 if len(agent) != 64 || strings.Trim(agent, "0123456789abcdef") != "" {
319 return nil, errors.New("invalid: agent public key required")
320 }
321 grant, ok, err := t.community.AgentGrant(ctx, agent)
322 if err != nil {
323 return nil, err
324 }
325 if !ok {
326 return nil, errors.New("not found: no agent grant for this pubkey")
327 }
328 rows, err := t.store.Query(ctx, event.Filter{Authors: []string{agent}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10})
329 if err != nil {
330 return nil, err
331 }
332 summary := community.AgentSummary{AgentGrant: grant}
333 if len(rows.Events) > 0 {
334 summary.LastEvent = rows.Events[0].CreatedAt
335 }
336 return map[string]any{"agent": summary, "events": rows.Events}, nil
337}
338
339func (t *Tenant) browseStatus(ctx context.Context, actor string) (any, error) {
340 role, err := t.community.Role(ctx, actor)
341 if err != nil {
342 return nil, err
343 }
344 if role != "owner" && role != "moderator" {
345 return nil, errors.New("restricted: operator status")
346 }
347 result := map[string]any{"capabilities": t.Capabilities(t.app.PeerMonitor()), "git_sync": nil, "jobs": nil, "delivery": nil, "storage": nil, "backups": nil}
348 failures := map[string]string{}
349 if t.git != nil {
350 status, err := t.git.GRASPService().SyncStatus(ctx)
351 if err != nil {
352 failures["git_sync"] = err.Error()
353 } else {
354 result["git_sync"] = status
355 }
356 }
357 for section, method := range map[string]string{"jobs": "listjobs", "delivery": "deliverystatus", "storage": "storagestats", "backups": "listbackups"} {
358 value, err := t.Execute(ctx, actor, method, nil)
359 if err != nil {
360 failures[section] = err.Error()
361 } else {
362 result[section] = value
363 }
364 }
365 if peers := t.app.PeerMonitor(); peers != nil {
366 result["peers"] = peers.Snapshot()
367 }
368 result["errors"] = failures
369 return result, nil
370}
371
372// browseProfile returns one key's profile as this relay holds it: the newest
373// kind 0 with its content parsed, and the write relays from the key's relay
374// list, so a profile editor can publish there too. The key defaults to the
375// caller's own.
376func (t *Tenant) browseProfile(ctx context.Context, actor, pubkey string) (any, error) {
377 if pubkey == "" {
378 pubkey = actor
379 }
380 if len(pubkey) != 64 || strings.Trim(pubkey, "0123456789abcdef") != "" {
381 return nil, errors.New("invalid: public key required")
382 }
383 if err := t.browseRead(ctx, actor); err != nil {
384 return nil, err
385 }
386 rows, err := t.store.Query(ctx, event.Filter{Authors: []string{pubkey}, Kinds: []int{event.KIND_PROFILE}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: browseSession(t, actor).PubKeys}, Limit: 1})
387 if err != nil {
388 return nil, err
389 }
390 result := map[string]any{"pubkey": pubkey, "event": nil, "profile": map[string]any{}, "relays": localDirectory{store: t.store}.WriteRelays(pubkey)}
391 if len(rows.Events) > 0 {
392 profile := map[string]any{}
393 if json.Unmarshal([]byte(rows.Events[0].Content), &profile) != nil {
394 profile = map[string]any{}
395 }
396 result["event"] = rows.Events[0]
397 result["profile"] = profile
398 }
399 return result, nil
400}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs