clone

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

feat/standalone-relay
tinyrelay / internal / daemon / client_browse.go
client_browse.go 12.8K
1package daemon23import (4 "bytes"5 "context"6 "encoding/json"7 "errors"8 "fmt"9 "io"10 "sort"11 "strings"12 "time"13 "unicode/utf8"1415 "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)2223type clientBrowseRequest struct {24 gitrelay.BrowseRequest25 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}3536func 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}3940func (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 err47}4849func (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, err60 }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 = 5087 }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.ID110 }111 return t.browseCollaborationDetail(ctx, actor, q, event.KIND_GIT_ISSUE)112 case "browsepull":113 if q.Event == "" {114 q.Event = q.ID115 }116 return t.browseCollaborationDetail(ctx, actor, q, event.KIND_GIT_PR)117 default:118 return nil, errors.New("unsupported: browse operation")119 }120}121122func (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 nil128 }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 err135 }136 if role == "owner" || role == "moderator" || role == "member" || t.IsMaintainer(ctx, r, actor) {137 return nil138 }139 return errors.New("restricted: private repository membership required")140}141142func (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.Identifier150 if cursor <= q.Cursor || (q.Query != "" && !strings.Contains(strings.ToLower(r.Identifier), strings.ToLower(q.Query))) {151 continue152 }153 if err := t.browseRepoAccess(ctx, actor, r); err != nil {154 continue155 }156 if len(items) == q.Limit {157 last := items[len(items)-1]158 next = last["owner"].(string) + "/" + last["identifier"].(string)159 break160 }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}, nil164}165166func (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, err173 }174 if err := t.browseRepoAccess(ctx, actor, r); err != nil {175 return nil, err176 }177 page, err := t.git.Browse(ctx, q.BrowseRequest)178 if err != nil {179 return nil, err180 }181 page.Private = page.Private || t.PrivateServiceEnabled()182 page.Maintainers = t.repositoryMaintainers(ctx, r)183 if q.View != "activity" {184 return page, nil185 }186 rows, err := t.browseRepoEvents(ctx, actor, r, q.Limit)187 return struct {188 gitrelay.BrowsePage189 Events []event.Event `json:"events"`190 }{page, rows}, err191}192193func (t *Tenant) browseRepoEvents(ctx context.Context, actor string, r gitrelay.Repository, limit int) ([]event.Event, error) {194 coordinate := "30617:" + r.Owner + ":" + r.Identifier195 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, err207 }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, err220 }221 rows = append(rows, replies...)222 }223 // Pending and rejected proposals from agents are left out for everyone224 // but the deciders and their author.225 if rows, _, err = t.collaborationVisible(ctx, t.collaborationRoles(r), actor, rows); err != nil {226 return nil, err227 }228 sort.Slice(rows, func(i, j int) bool {229 if rows[i].CreatedAt == rows[j].CreatedAt {230 return rows[i].ID < rows[j].ID231 }232 return rows[i].CreatedAt > rows[j].CreatedAt233 })234 seen := map[string]bool{}235 result := []event.Event{}236 for _, row := range rows {237 if !seen[row.ID] {238 seen[row.ID] = true239 result = append(result, row)240 }241 if len(result) == limit {242 break243 }244 }245 return result, nil246}247248func (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, err254 }255 if err := t.roomAttachmentAccess(ctx, actor, hash); err != nil {256 return nil, err257 }258 entry, body, err := t.blobs.Get(ctx, hash)259 if err != nil {260 return nil, err261 }262 defer body.Close()263 data, err := io.ReadAll(io.LimitReader(body, 256*1024+1))264 if err != nil {265 return nil, err266 }267 truncated := len(data) > 256*1024268 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"] = access275 }276 if actor != "" {277 var name, filePath string278 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"] = name287 }288 if filePath != "" {289 result["path"] = filePath290 }291 }292 }293 result["binary"], result["truncated"] = binary, truncated294 if entry.Type == "application/octet-stream" {295 if detected := blob.DetectMediaType(data); detected != "application/octet-stream" {296 result["type"] = detected297 }298 }299 if actor == "" {300 delete(result, "uploader")301 }302 if !binary {303 result["content"] = strings.ToValidUTF8(string(data), "�")304 }305 return result, nil306}307308// browseAgent returns one agent grant with the agent's newest events for the309// 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, err314 }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, err324 }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, err331 }332 summary := community.AgentSummary{AgentGrant: grant}333 if len(rows.Events) > 0 {334 summary.LastEvent = rows.Events[0].CreatedAt335 }336 return map[string]any{"agent": summary, "events": rows.Events}, nil337}338339func (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, err343 }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"] = status355 }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] = value363 }364 }365 if peers := t.app.PeerMonitor(); peers != nil {366 result["peers"] = peers.Snapshot()367 }368 result["errors"] = failures369 return result, nil370}371372// browseProfile returns one key's profile as this relay holds it: the newest373// kind 0 with its content parsed, and the write relays from the key's relay374// list, so a profile editor can publish there too. The key defaults to the375// caller's own.376func (t *Tenant) browseProfile(ctx context.Context, actor, pubkey string) (any, error) {377 if pubkey == "" {378 pubkey = actor379 }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, err385 }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, err389 }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"] = profile398 }399 return result, nil400}