| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 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 | |
| 23 | type 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 | |
| 36 | func 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 | |
| 40 | func (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 | |
| 49 | func (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 | |
| 122 | func (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 | |
| 142 | func (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 | |
| 166 | func (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 | |
| 193 | func (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 | |
| 248 | func (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. |
| 310 | func (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 | |
| 339 | func (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. |
| 376 | func (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 | } |