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", "browsegrant", "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 "browsegrant":102 return t.browseGrant(ctx, actor, params)103 case "browseprofile":104 return t.browseProfile(ctx, actor, q.Pubkey)105 case "browseissues":106 return t.browseCollaboration(ctx, actor, q, event.KIND_GIT_ISSUE)107 case "browsepulls":108 return t.browseCollaboration(ctx, actor, q, event.KIND_GIT_PR)109 case "browseissue":110 if q.Event == "" {111 q.Event = q.ID112 }113 return t.browseCollaborationDetail(ctx, actor, q, event.KIND_GIT_ISSUE)114 case "browsepull":115 if q.Event == "" {116 q.Event = q.ID117 }118 return t.browseCollaborationDetail(ctx, actor, q, event.KIND_GIT_PR)119 default:120 return nil, errors.New("unsupported: browse operation")121 }122}123124func (t *Tenant) browseRepoAccess(ctx context.Context, actor string, r gitrelay.Repository) error {125 if !t.Policy().Features.Grasp || t.git == nil {126 return errors.New("not found: Git hosting is disabled")127 }128 if !r.Private {129 return nil130 }131 if actor == "" {132 return errors.New("auth-required: private repository")133 }134 role, err := t.community.Role(ctx, actor)135 if err != nil {136 return err137 }138 if role == "owner" || role == "moderator" || role == "member" || t.IsMaintainer(ctx, r, actor) {139 return nil140 }141 return errors.New("restricted: private repository membership required")142}143144func (t *Tenant) browseRepos(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {145 if !t.Policy().Features.Grasp || t.git == nil {146 return nil, errors.New("not found: Git hosting is disabled")147 }148 items := []map[string]any{}149 next := ""150 for _, r := range t.git.Repositories() {151 cursor := r.Owner + "/" + r.Identifier152 if cursor <= q.Cursor || (q.Query != "" && !strings.Contains(strings.ToLower(r.Identifier), strings.ToLower(q.Query))) {153 continue154 }155 if err := t.browseRepoAccess(ctx, actor, r); err != nil {156 continue157 }158 if len(items) == q.Limit {159 last := items[len(items)-1]160 next = last["owner"].(string) + "/" + last["identifier"].(string)161 break162 }163 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})164 }165 return map[string]any{"items": items, "next_cursor": next}, nil166}167168func (t *Tenant) browseRepo(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {169 if t.git == nil {170 return nil, errors.New("not found: Git hosting")171 }172 r, err := t.git.BrowseRepository(q.Owner, q.Repo)173 if err != nil {174 return nil, err175 }176 if err := t.browseRepoAccess(ctx, actor, r); err != nil {177 return nil, err178 }179 page, err := t.git.Browse(ctx, q.BrowseRequest)180 if err != nil {181 return nil, err182 }183 page.Private = page.Private || t.PrivateServiceEnabled()184 page.Maintainers = t.repositoryMaintainers(ctx, r)185 if q.View != "activity" {186 return page, nil187 }188 rows, err := t.browseRepoEvents(ctx, actor, r, q.Limit)189 return struct {190 gitrelay.BrowsePage191 Events []event.Event `json:"events"`192 }{page, rows}, err193}194195func (t *Tenant) browseRepoEvents(ctx context.Context, actor string, r gitrelay.Repository, limit int) ([]event.Event, error) {196 coordinate := "30617:" + r.Owner + ":" + r.Identifier197 kinds := []int{1617, 1618, 1619, 1621, 1111, 1630, 1631, 1632, 1633}198 filters := []event.Filter{}199 for _, tag := range []string{"a", "A"} {200 filters = append(filters, event.Filter{Kinds: kinds, Tags: map[string][]string{tag: {coordinate}}, Limit: &limit})201 }202 session := relay.Session{RelayURL: t.RelayURL()}203 if actor != "" {204 session.PubKeys = []string{actor}205 }206 rows, err := t.Query(ctx, filters, session)207 if err != nil {208 return nil, err209 }210 ids := []string{}211 for _, row := range rows {212 ids = append(ids, row.ID)213 }214 if len(ids) > 0 {215 threads := []event.Filter{}216 for _, tag := range []string{"e", "E"} {217 threads = append(threads, event.Filter{Kinds: kinds, Tags: map[string][]string{tag: ids}, Limit: &limit})218 }219 replies, err := t.Query(ctx, threads, session)220 if err != nil {221 return nil, err222 }223 rows = append(rows, replies...)224 }225 // Pending and rejected proposals from agents are left out for everyone226 // but the deciders and their author.227 if rows, _, err = t.collaborationVisible(ctx, t.collaborationRoles(r), actor, rows); err != nil {228 return nil, err229 }230 sort.Slice(rows, func(i, j int) bool {231 if rows[i].CreatedAt == rows[j].CreatedAt {232 return rows[i].ID < rows[j].ID233 }234 return rows[i].CreatedAt > rows[j].CreatedAt235 })236 seen := map[string]bool{}237 result := []event.Event{}238 for _, row := range rows {239 if !seen[row.ID] {240 seen[row.ID] = true241 result = append(result, row)242 }243 if len(result) == limit {244 break245 }246 }247 return result, nil248}249250func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error) {251 if !t.Policy().Features.Files {252 return nil, errors.New("not found: files are disabled")253 }254 if err := t.fileReadAccess(ctx, actor, hash); err != nil {255 return nil, err256 }257 if err := t.roomAttachmentAccess(ctx, actor, hash); err != nil {258 return nil, err259 }260 entry, body, err := t.blobs.Get(ctx, hash)261 if err != nil {262 return nil, err263 }264 defer body.Close()265 data, err := io.ReadAll(io.LimitReader(body, 256*1024+1))266 if err != nil {267 return nil, err268 }269 truncated := len(data) > 256*1024270 if truncated {271 data = data[:256*1024]272 }273 binary := bytes.IndexByte(data, 0) >= 0 || (!utf8.Valid(data) && !truncated)274 result := t.browseBlobMetadata(entry)275 if access, accessErr := t.blobs.BlobAccess(ctx, entry.SHA256); accessErr == nil {276 result["access"] = access277 }278 if actor != "" {279 var name, filePath string280 metadataQuery := `SELECT name,path FROM blob_claim_metadata WHERE sha256=? AND uploader=? ORDER BY updated_at DESC,path LIMIT 1`281 metadataArgs := []any{entry.SHA256, actor}282 if access, accessErr := t.blobs.BlobAccess(ctx, entry.SHA256); accessErr == nil && access == blob.AccessMembers {283 metadataQuery = `SELECT name,path FROM blob_claim_metadata WHERE sha256=? ORDER BY updated_at DESC,path LIMIT 1`284 metadataArgs = []any{entry.SHA256}285 }286 if err := t.store.DB().QueryRowContext(ctx, metadataQuery, metadataArgs...).Scan(&name, &filePath); err == nil {287 if name != "" {288 result["name"] = name289 }290 if filePath != "" {291 result["path"] = filePath292 }293 }294 }295 result["binary"], result["truncated"] = binary, truncated296 if entry.Type == "application/octet-stream" {297 if detected := blob.DetectMediaType(data); detected != "application/octet-stream" {298 result["type"] = detected299 }300 }301 if actor == "" {302 delete(result, "uploader")303 }304 if !binary {305 result["content"] = strings.ToValidUTF8(string(data), "�")306 }307 return result, nil308}309310// browseAgent returns one agent grant with the agent's newest events for the311// Manage > Agents page. Like listagents, it is for the owner and moderators.312func (t *Tenant) browseAgent(ctx context.Context, actor, agent string) (any, error) {313 role, err := t.community.Role(ctx, actor)314 if err != nil {315 return nil, err316 }317 if role != "owner" && role != "moderator" {318 return nil, errors.New("restricted: agent details")319 }320 if len(agent) != 64 || strings.Trim(agent, "0123456789abcdef") != "" {321 return nil, errors.New("invalid: agent public key required")322 }323 grant, ok, err := t.community.AgentGrant(ctx, agent)324 if err != nil {325 return nil, err326 }327 if !ok {328 return nil, errors.New("not found: no agent grant for this pubkey")329 }330 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})331 if err != nil {332 return nil, err333 }334 summary := community.AgentSummary{AgentGrant: grant}335 if len(rows.Events) > 0 {336 summary.LastEvent = rows.Events[0].CreatedAt337 }338 return map[string]any{"agent": summary, "events": rows.Events}, nil339}340341func (t *Tenant) browseStatus(ctx context.Context, actor string) (any, error) {342 role, err := t.community.Role(ctx, actor)343 if err != nil {344 return nil, err345 }346 if role != "owner" && role != "moderator" {347 return nil, errors.New("restricted: operator status")348 }349 result := map[string]any{"capabilities": t.Capabilities(t.app.PeerMonitor()), "git_sync": nil, "jobs": nil, "delivery": nil, "storage": nil, "backups": nil}350 failures := map[string]string{}351 if t.git != nil {352 status, err := t.git.GRASPService().SyncStatus(ctx)353 if err != nil {354 failures["git_sync"] = err.Error()355 } else {356 result["git_sync"] = status357 }358 }359 for section, method := range map[string]string{"jobs": "listjobs", "delivery": "deliverystatus", "storage": "storagestats", "backups": "listbackups"} {360 value, err := t.Execute(ctx, actor, method, nil)361 if err != nil {362 failures[section] = err.Error()363 } else {364 result[section] = value365 }366 }367 if peers := t.app.PeerMonitor(); peers != nil {368 result["peers"] = peers.Snapshot()369 }370 result["errors"] = failures371 return result, nil372}373374// browseProfile returns one key's profile as this relay holds it: the newest375// kind 0 with its content parsed, and the write relays from the key's relay376// list, so a profile editor can publish there too. The key defaults to the377// caller's own.378func (t *Tenant) browseProfile(ctx context.Context, actor, pubkey string) (any, error) {379 if pubkey == "" {380 pubkey = actor381 }382 if len(pubkey) != 64 || strings.Trim(pubkey, "0123456789abcdef") != "" {383 return nil, errors.New("invalid: public key required")384 }385 if err := t.browseRead(ctx, actor); err != nil {386 return nil, err387 }388 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})389 if err != nil {390 return nil, err391 }392 result := map[string]any{"pubkey": pubkey, "event": nil, "profile": map[string]any{}, "relays": localDirectory{store: t.store}.WriteRelays(pubkey)}393 if len(rows.Events) > 0 {394 profile := map[string]any{}395 if json.Unmarshal([]byte(rows.Events[0].Content), &profile) != nil {396 profile = map[string]any{}397 }398 result["event"] = rows.Events[0]399 result["profile"] = profile400 }401 return result, nil402}