tinyrelayfile
tinyrelay/internal/daemon/management.go/

internal/daemon/management.go raw
1package daemon
2
3// This file contains the second half of the management registry. Keeping the
4// adapters here lets HTTP, the plain HTML UI, and future CLI callers share the
5// same source-shaped responses while the core Tenant type remains focused on
6// protocol admission.
7
8import (
9 "context"
10 "database/sql"
11 "encoding/hex"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "net/url"
16 "path"
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/FelineStateMachine/tinyrelay/internal/catalog"
22 "github.com/FelineStateMachine/tinyrelay/internal/community"
23 "github.com/FelineStateMachine/tinyrelay/internal/domains"
24 "github.com/FelineStateMachine/tinyrelay/internal/event"
25 "github.com/FelineStateMachine/tinyrelay/internal/policy"
26 "github.com/FelineStateMachine/tinyrelay/internal/relay"
27 "github.com/FelineStateMachine/tinyrelay/internal/sites"
28 "github.com/FelineStateMachine/tinyrelay/internal/storage"
29 "github.com/FelineStateMachine/tinyrelay/internal/templates"
30)
31
32// executeManagement handles methods that are not owned by community,
33// records, configport, or replication. handled is false when the caller
34// should continue through the existing registry.
35func (t *Tenant) executeManagement(ctx context.Context, actor, method string, params []json.RawMessage) (result any, handled bool, err error) {
36 if clientBrowseMethod(method) {
37 result, err = t.executeBrowse(ctx, actor, method, params)
38 return result, true, err
39 }
40 if roomBrowseMethod(method) {
41 result, err = t.executeRoomBrowse(ctx, actor, method, params)
42 return result, true, err
43 }
44 if publicManagementMethod(method) {
45 return t.executePublicManagement(ctx, actor, method, params)
46 }
47 if callbackMethod(method) {
48 // Members and agents manage their own callbacks, so the role rule
49 // lives with the methods.
50 result, err = t.callbackExecute(ctx, actor, method, params)
51 return result, true, err
52 }
53 if customViewMethod(method) {
54 result, err = t.customViews.customViewExecute(ctx, actor, method, params)
55 return result, true, err
56 }
57 if !managementAdapterMethod(method) {
58 return nil, false, nil
59 }
60 role, roleErr := t.community.Role(ctx, actor)
61 if roleErr != nil {
62 return nil, true, roleErr
63 }
64 if !managementRoleAllows(method, role) {
65 return nil, true, fmt.Errorf("restricted: %s may not %s", roleName(role), method)
66 }
67 switch method {
68 case "listconnectiontemplates":
69 return t.connectionTemplates(), true, nil
70 case "listconnections":
71 return t.connections(ctx)
72 case "setconnections":
73 return t.setConnections(ctx, params)
74 case "changerelayname":
75 return t.changeIdentity(ctx, params, "name")
76 case "changerelaydescription":
77 return t.changeIdentity(ctx, params, "description")
78 case "changerelayicon":
79 return t.changeIdentity(ctx, params, "icon")
80 case "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains":
81 return t.domainExecute(ctx, actor, method, params)
82 case "listblobs":
83 return t.listBlobs(ctx, params)
84 case "deleteblob":
85 return t.deleteBlob(ctx, params)
86 case "listsites":
87 return t.listSites(ctx)
88 case "deleteevent":
89 return t.deleteEvent(ctx, params)
90 case "listrecentevents":
91 return t.recentEvents(ctx, params)
92 case "searchevents":
93 return t.searchEvents(ctx, params)
94 case "storagestats":
95 return t.storageStats(ctx)
96 case "publishview":
97 return t.publishView(ctx, params)
98 case "gitstorage":
99 return t.gitStorage(ctx, params)
100 case "resetrules":
101 return t.resetRules(ctx)
102 case "getidentity":
103 return t.identity(), true, nil
104 case "transferowner":
105 return t.transferOwner(ctx, actor, params)
106 case "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents":
107 return t.agentExecute(ctx, actor, method, params)
108 case "forkrelay":
109 return t.forkRelay(ctx, actor, params)
110 case "deleterelay":
111 return t.deleteRelay(ctx, params)
112 default:
113 return nil, true, fmt.Errorf("unsupported: management method %q", method)
114 }
115}
116
117func managementAdapterMethod(method string) bool {
118 for _, name := range []string{"listconnectiontemplates", "listconnections", "setconnections", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listblobs", "deleteblob", "listsites", "deleteevent", "listrecentevents", "searchevents", "storagestats", "publishview", "gitstorage", "resetrules", "getidentity", "transferowner", "forkrelay", "deleterelay", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} {
119 if method == name {
120 return true
121 }
122 }
123 return false
124}
125
126// PublicMethods are backend calls used by the unauthenticated HTML pages. They
127// are intentionally separate from the NIP-86 management methods so a public
128// page cannot accidentally gain access to privileged search or export APIs.
129func PublicMethods() []string {
130 return []string{"queryevents", "eventdetail", "feed", "listview", "claiminvite", "connections"}
131}
132
133func publicManagementMethod(method string) bool {
134 for _, name := range PublicMethods() {
135 if method == name {
136 return true
137 }
138 }
139 return false
140}
141
142// ManagementMethods is the source-compatible NIP-86 registry. The hosted
143// lease, trial, fuel, and entitlement controls are intentionally absent.
144func ManagementMethods() []string {
145 return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "listjoinrequests", "approvejoin", "denyjoin", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback", "listcustomviews", "addcustomview", "removecustomview", "runcustomview", "pausecustomview", "resumecustomview"}
146}
147
148func managementRoleAllows(method, role string) bool {
149 if role == "owner" {
150 return true
151 }
152 if role != "moderator" {
153 return false
154 }
155 for _, name := range []string{"listconnectiontemplates", "listconnections", "listblobs", "listsites", "listrecentevents", "searchevents", "storagestats", "deleteevent", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} {
156 if method == name {
157 return true
158 }
159 }
160 return false
161}
162
163func roleName(role string) string {
164 if role == "" {
165 return "guest"
166 }
167 return role
168}
169
170func (t *Tenant) executePublicManagement(ctx context.Context, actor, method string, params []json.RawMessage) (any, bool, error) {
171 if method == "connections" {
172 return t.visibleConnections(ctx, actor)
173 }
174 if method == "claiminvite" {
175 if strings.TrimSpace(actor) == "" {
176 return nil, true, errors.New("auth-required: claiminvite")
177 }
178 code := stringParam(params, 0)
179 terms := stringParam(params, 1)
180 if code == "" {
181 return nil, true, errors.New("invalid: invite code required")
182 }
183 if _, err := t.community.ClaimWithTerms(ctx, code, actor, terms); err != nil {
184 return nil, true, err
185 }
186 return map[string]any{"claimed": true, "code": code}, true, nil
187 }
188 if t.Policy().Reads != "open" && strings.TrimSpace(actor) == "" {
189 return nil, true, fmt.Errorf("auth-required: %s", method)
190 }
191 if method == "listview" {
192 return t.publicView(ctx, actor, params)
193 }
194 var filter event.Filter
195 var err error
196 switch method {
197 case "eventdetail":
198 id := stringParam(params, 0)
199 if id == "" {
200 return nil, true, errors.New("invalid: event id required")
201 }
202 filter = event.Filter{IDs: []string{id}, Tags: map[string][]string{}, Limit: mgmtIntPtr(1)}
203 case "queryevents":
204 filter, err = publicFilter(params)
205 case "feed":
206 filter = event.Filter{Kinds: []int{1, 30023}, Tags: map[string][]string{}, Limit: mgmtIntPtr(50)}
207 }
208 if err != nil {
209 return nil, true, err
210 }
211 keys := []string{}
212 if actor != "" {
213 keys = []string{actor}
214 }
215 rows, err := t.Query(ctx, []event.Filter{filter}, relay.Session{PubKeys: keys, RelayURL: t.publicURL})
216 if err != nil {
217 return nil, true, err
218 }
219 if method == "eventdetail" {
220 if len(rows) == 0 {
221 return nil, true, sql.ErrNoRows
222 }
223 return rows[0], true, nil
224 }
225 return rows, true, nil
226}
227
228func publicFilter(params []json.RawMessage) (event.Filter, error) {
229 if len(params) == 0 {
230 return event.Filter{Tags: map[string][]string{}, Limit: mgmtIntPtr(50)}, nil
231 }
232 f, err := event.ParseFilter(params[0])
233 if err != nil {
234 return event.Filter{}, errors.New("invalid: event filter")
235 }
236 if f.Limit == nil {
237 f.Limit = mgmtIntPtr(50)
238 }
239 if *f.Limit < 1 {
240 return event.Filter{}, errors.New("invalid: filter limit")
241 }
242 return f, nil
243}
244
245func (t *Tenant) publicView(ctx context.Context, actor string, params []json.RawMessage) (any, bool, error) {
246 name := stringParam(params, 0)
247 if name == "" {
248 return nil, true, errors.New("invalid: view name required")
249 }
250 role, err := t.community.Role(ctx, actor)
251 if err != nil {
252 return nil, true, err
253 }
254 owner := role == "owner"
255 member := role == "member" || role == "moderator" || owner
256 keys := []string{}
257 if actor != "" {
258 keys = append(keys, actor)
259 }
260 view, err := t.records.View(ctx, name, policy.Access{Owner: owner, Member: member, PubKeys: keys}, time.Now().Unix())
261 if err != nil {
262 return nil, true, err
263 }
264 return view.Event, true, nil
265}
266
267func mgmtIntPtr(value int) *int { return &value }
268
269func (t *Tenant) identity() map[string]any {
270 p := t.Policy()
271 return map[string]any{"owner": p.Owner, "name": p.Name, "description": p.Description, "icon": p.Icon, "relayURL": t.publicURL, "identity": t.records.PublicKey()}
272}
273
274func (t *Tenant) transferOwner(ctx context.Context, actor string, params []json.RawMessage) (any, bool, error) {
275 next := stringParam(params, 0)
276 if next == "" || len(next) != 64 {
277 return nil, true, errors.New("invalid: owner public key required")
278 }
279 if _, err := hex.DecodeString(next); err != nil {
280 return nil, true, errors.New("invalid: owner public key required")
281 }
282 current := t.Policy().Owner
283 if actor != current {
284 return nil, true, errors.New("restricted: owner required")
285 }
286 nextPolicy, err := policy.Patch(t.Policy(), map[string]json.RawMessage{"owner": json.RawMessage(strconv.Quote(next))})
287 if err != nil {
288 return nil, true, err
289 }
290 if err := t.applyPolicy(ctx, nextPolicy); err != nil {
291 return nil, true, err
292 }
293 return map[string]any{"owner": next, "previousOwner": current}, true, nil
294}
295
296// agentExecute serves the agent kill switch and inventory. The community
297// service records each change in the audit log; this adapter only shapes
298// parameters and logs counts.
299func (t *Tenant) agentExecute(ctx context.Context, actor, method string, params []json.RawMessage) (any, bool, error) {
300 now := time.Now().Unix()
301 switch method {
302 case "listagents":
303 agents, err := t.community.Agents(ctx)
304 return agents, true, err
305 case "pauseallagents", "resumeallagents":
306 changed, err := t.community.SetAllAgentsPaused(ctx, actor, method == "pauseallagents")
307 if err != nil {
308 return nil, true, err
309 }
310 t.app.telemetry.Logger().Info("agent grants changed", "tenant", t.meta.Name, "action", method, "changed", changed)
311 return map[string]any{"changed": changed, "paused": method == "pauseallagents"}, true, nil
312 }
313 agent := stringParam(params, 0)
314 if len(agent) != 64 {
315 return nil, true, errors.New("invalid: agent public key required")
316 }
317 if _, err := hex.DecodeString(agent); err != nil {
318 return nil, true, errors.New("invalid: agent public key required")
319 }
320 var grant community.AgentGrant
321 var err error
322 switch method {
323 case "pauseagent", "resumeagent":
324 grant, err = t.community.SetAgentPaused(ctx, actor, agent, method == "pauseagent")
325 case "revokeagent":
326 grant, err = t.community.RevokeAgent(ctx, actor, agent, now)
327 }
328 if err != nil {
329 return nil, true, err
330 }
331 return grant, true, nil
332}
333
334func (t *Tenant) deleteRelay(ctx context.Context, params []json.RawMessage) (any, bool, error) {
335 name := stringParam(params, 0)
336 if name == "" || !strings.EqualFold(name, t.meta.Name) {
337 return nil, true, errors.New("invalid: relay name confirmation required")
338 }
339 if err := t.app.deleteTenant(ctx, t.meta.ID); err != nil {
340 return nil, true, err
341 }
342 return map[string]any{"deleted": true, "name": t.meta.Name}, true, nil
343}
344
345func (t *Tenant) forkRelay(ctx context.Context, actor string, params []json.RawMessage) (any, bool, error) {
346 if len(params) != 1 {
347 return nil, true, errors.New("invalid: fork options required")
348 }
349 var options struct {
350 Name string `json:"name"`
351 Owner string `json:"owner"`
352 Kinds []int `json:"kinds"`
353 People bool `json:"people"`
354 Filter *event.Filter `json:"filter"`
355 }
356 if err := json.Unmarshal(params[0], &options); err != nil {
357 return nil, true, errors.New("invalid: fork options")
358 }
359 if strings.TrimSpace(options.Name) == "" {
360 options.Name = fmt.Sprintf("fork-%d", time.Now().UnixNano())
361 }
362 if options.Owner == "" {
363 options.Owner = actor
364 }
365 if len(options.Owner) != 64 {
366 return nil, true, errors.New("invalid: fork owner")
367 }
368 meta, err := t.app.Create(ctx, catalog.CreateOptions{Name: options.Name, Owner: options.Owner, Template: t.meta.Template})
369 if err != nil {
370 return nil, true, err
371 }
372 newURL := t.app.forkRelayURL(t, meta.Name)
373 target, err := t.app.tenant(ctx, meta, newURL)
374 if err != nil {
375 return nil, true, err
376 }
377 filter := event.Filter{Tags: map[string][]string{}}
378 if options.Filter != nil {
379 filter = *options.Filter
380 if filter.Tags == nil {
381 filter.Tags = map[string][]string{}
382 }
383 }
384 if len(options.Kinds) > 0 && len(filter.Kinds) == 0 {
385 filter.Kinds = options.Kinds
386 }
387 rows, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
388 if err != nil {
389 return nil, true, err
390 }
391 for _, copied := range rows.Events {
392 if _, err := target.store.Save(ctx, copied, storage.SaveOptions{Now: time.Now().Unix(), SearchMode: target.Policy().Features.Search}); err != nil {
393 return nil, true, fmt.Errorf("fork event %s: %w", copied.ID, err)
394 }
395 }
396 if err := copyForkConfig(ctx, t.store, target.store); err != nil {
397 return nil, true, err
398 }
399 if options.People {
400 if err := copyForkPeople(ctx, t.store, target.store, options.Owner); err != nil {
401 return nil, true, err
402 }
403 }
404 return map[string]any{"id": meta.ID, "name": meta.Name, "owner": meta.Owner, "relayURL": newURL, "events": len(rows.Events), "people": options.People}, true, nil
405}
406
407func (a *App) forkRelayURL(source *Tenant, name string) string {
408 base := strings.TrimSuffix(a.cfg.PublicURL, "/")
409 if base != "" {
410 return base + "/r/" + name
411 }
412 u, err := url.Parse(source.publicURL)
413 if err != nil {
414 return strings.TrimSuffix(source.publicURL, "/") + "/r/" + name
415 }
416 prefix := "/r/" + source.meta.Name
417 if strings.HasSuffix(u.Path, prefix) {
418 u.Path = strings.TrimSuffix(u.Path, prefix)
419 }
420 u.Path = strings.TrimSuffix(u.Path, "/") + "/r/" + name
421 return strings.TrimSuffix(u.String(), "/")
422}
423
424func copyForkConfig(ctx context.Context, source, target *storage.Store) error {
425 return target.WithTx(ctx, func(tx *sql.Tx) error {
426 for _, table := range []struct {
427 name string
428 insert string
429 query string
430 }{
431 {"config_connections", "INSERT OR REPLACE INTO config_connections(position,value) VALUES(?,?)", "SELECT position,value FROM config_connections ORDER BY position"},
432 {"community_kind_rules", "INSERT OR REPLACE INTO community_kind_rules(kind,rule) VALUES(?,?)", "SELECT kind,rule FROM community_kind_rules"},
433 {"community_retention", "INSERT OR REPLACE INTO community_retention(kind,days) VALUES(?,?)", "SELECT kind,days FROM community_retention"},
434 } {
435 rows, err := source.DB().QueryContext(ctx, table.query)
436 if err != nil {
437 return fmt.Errorf("fork %s: %w", table.name, err)
438 }
439 for rows.Next() {
440 var first, second any
441 if err := rows.Scan(&first, &second); err != nil {
442 _ = rows.Close()
443 return err
444 }
445 if _, err := tx.ExecContext(ctx, table.insert, first, second); err != nil {
446 _ = rows.Close()
447 return err
448 }
449 }
450 if err := rows.Err(); err != nil {
451 _ = rows.Close()
452 return err
453 }
454 _ = rows.Close()
455 }
456 return nil
457 })
458}
459
460func copyForkPeople(ctx context.Context, source, target *storage.Store, owner string) error {
461 rows, err := source.DB().QueryContext(ctx, `SELECT pubkey,name,note,role,invited_by,via,created_at,keep_days FROM community_members WHERE role<>'owner' AND role<>'agent'`)
462 if err != nil {
463 return fmt.Errorf("fork members: %w", err)
464 }
465 defer rows.Close()
466 return target.WithTx(ctx, func(tx *sql.Tx) error {
467 for rows.Next() {
468 var pubkey, name, note, role, invitedBy, via string
469 var createdAt, keepDays int64
470 if err := rows.Scan(&pubkey, &name, &note, &role, &invitedBy, &via, &createdAt, &keepDays); err != nil {
471 return err
472 }
473 if pubkey == owner {
474 continue
475 }
476 if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO community_members(pubkey,name,note,role,invited_by,via,created_at,keep_days) VALUES(?,?,?,?,?,?,?,?)`, pubkey, name, note, role, invitedBy, via, createdAt, keepDays); err != nil {
477 return err
478 }
479 }
480 return rows.Err()
481 })
482}
483
484// AddressFilterForPath turns the plain HTML address routes into storage
485// filters. It is also used by NIP-AD discovery handlers to keep route and
486// discovery semantics aligned.
487func AddressFilterForPath(rawPath, author, identity string) (map[string]any, bool) {
488 p := strings.TrimPrefix(path.Clean("/"+rawPath), "/")
489 parts := strings.Split(p, "/")
490 if len(parts) != 2 || parts[1] == "" {
491 return nil, false
492 }
493 value, err := url.PathUnescape(parts[1])
494 if err != nil || strings.ContainsAny(value, "/\\") {
495 return nil, false
496 }
497 switch parts[0] {
498 case "e":
499 if len(value) != 64 {
500 return nil, false
501 }
502 if _, err := hex.DecodeString(value); err != nil {
503 return nil, false
504 }
505 return map[string]any{"ids": []string{value}, "kinds": []int{1, 30023}, "limit": 1}, true
506 case "a":
507 if author == "" {
508 return nil, false
509 }
510 return map[string]any{"authors": []string{author}, "kinds": []int{30023}, "#d": []string{value}, "limit": 1}, true
511 case "view":
512 if identity == "" {
513 return nil, false
514 }
515 return map[string]any{"authors": []string{identity}, "kinds": []int{30078}, "#d": []string{"bind.ws/view/" + value}, "limit": 1}, true
516 default:
517 return nil, false
518 }
519}
520
521func (t *Tenant) connectionTemplates() []map[string]any {
522 result := make([]map[string]any, 0)
523 p := t.Policy()
524 for _, c := range templates.Connections() {
525 available := c.Feature == "" || featureEnabled(p, c.Feature)
526 result = append(result, map[string]any{"name": c.Name, "title": c.Title, "about": c.About, "app": c.App, "where": c.Where, "icon": c.Icon, "feature": c.Feature, "visibility": c.Visibility, "qr": c.QR, "inputs": c.Inputs, "links": c.Links, "available": available})
527 }
528 return result
529}
530
531func featureEnabled(p policy.Policy, name string) bool {
532 switch name {
533 case "files":
534 return p.Features.Files
535 case "sites":
536 return p.Features.Sites.Enabled
537 case "grasp":
538 return p.Features.Grasp
539 case "marmot":
540 return p.Features.Marmot
541 case "search":
542 return p.Features.Search != "off"
543 default:
544 return false
545 }
546}
547
548// visibleConnections lists configured connections the viewer may see, each
549// merged with its template so pages can render titles, links and QR codes.
550// Visibility "public" shows to everyone, "members" or "auth" to members,
551// moderators and the owner, and "owner" to the owner alone. Connections
552// whose feature is off are left out.
553func (t *Tenant) visibleConnections(ctx context.Context, actor string) (any, bool, error) {
554 rows, _, err := t.connections(ctx)
555 if err != nil {
556 return nil, true, err
557 }
558 role := ""
559 if strings.TrimSpace(actor) != "" {
560 if role, err = t.community.Role(ctx, actor); err != nil {
561 return nil, true, err
562 }
563 }
564 catalog := map[string]templates.Connection{}
565 for _, c := range templates.Connections() {
566 catalog[c.Name] = c
567 }
568 p := t.Policy()
569 result := make([]map[string]any, 0)
570 for _, row := range rows.([]map[string]any) {
571 name, _ := row["template"].(string)
572 if name == "" {
573 name, _ = row["name"].(string)
574 }
575 c, ok := catalog[name]
576 if !ok || (c.Feature != "" && !featureEnabled(p, c.Feature)) {
577 continue
578 }
579 visibility, _ := row["visibility"].(string)
580 if visibility == "" {
581 visibility = c.Visibility
582 }
583 switch visibility {
584 case "public":
585 case "members", "member", "auth":
586 if role != "member" && role != "moderator" && role != "owner" {
587 continue
588 }
589 default:
590 if role != "owner" {
591 continue
592 }
593 }
594 result = append(result, map[string]any{"template": c.Name, "title": c.Title, "about": c.About, "app": c.App, "where": c.Where, "icon": c.Icon, "feature": c.Feature, "visibility": visibility, "qr": c.QR, "inputs": c.Inputs, "links": c.Links})
595 }
596 return result, true, nil
597}
598
599func (t *Tenant) connections(ctx context.Context) (any, bool, error) {
600 rows, err := t.store.DB().QueryContext(ctx, `SELECT value FROM config_connections ORDER BY position`)
601 if err != nil {
602 return nil, true, fmt.Errorf("list connections: %w", err)
603 }
604 defer rows.Close()
605 result := []map[string]any{}
606 for rows.Next() {
607 var raw string
608 if err := rows.Scan(&raw); err != nil {
609 return nil, true, err
610 }
611 var value map[string]any
612 if err := json.Unmarshal([]byte(raw), &value); err != nil {
613 return nil, true, fmt.Errorf("decode connection: %w", err)
614 }
615 result = append(result, value)
616 }
617 return result, true, rows.Err()
618}
619
620func (t *Tenant) setConnections(ctx context.Context, params []json.RawMessage) (any, bool, error) {
621 if len(params) != 1 {
622 return nil, true, errors.New("invalid: setconnections expects one list")
623 }
624 var values []map[string]any
625 if err := json.Unmarshal(params[0], &values); err != nil {
626 return nil, true, errors.New("invalid: connections must be a list")
627 }
628 if err := t.store.WithTx(ctx, func(tx *sql.Tx) error {
629 if _, err := tx.ExecContext(ctx, `DELETE FROM config_connections`); err != nil {
630 return err
631 }
632 for i, value := range values {
633 raw, err := json.Marshal(value)
634 if err != nil {
635 return err
636 }
637 if _, err := tx.ExecContext(ctx, `INSERT INTO config_connections(position,value) VALUES(?,?)`, i, raw); err != nil {
638 return err
639 }
640 }
641 return nil
642 }); err != nil {
643 return nil, true, fmt.Errorf("save connections: %w", err)
644 }
645 return values, true, nil
646}
647
648func (t *Tenant) changeIdentity(ctx context.Context, params []json.RawMessage, key string) (any, bool, error) {
649 if len(params) != 1 {
650 return nil, true, errors.New("invalid: identity value required")
651 }
652 var value string
653 if err := json.Unmarshal(params[0], &value); err != nil {
654 return nil, true, errors.New("invalid: identity value must be a string")
655 }
656 limit := 2000
657 if key == "name" {
658 limit = 200
659 }
660 if len(value) > limit {
661 value = value[:limit]
662 }
663 _, err := t.setPolicy(ctx, map[string]json.RawMessage{key: json.RawMessage(strconv.Quote(value))})
664 return true, true, err
665}
666
667func (t *Tenant) domainExecute(ctx context.Context, actor, method string, params []json.RawMessage) (any, bool, error) {
668 s, err := domains.New(domains.Config{Catalog: t.app.catalog, TenantID: t.meta.ID, BaseHost: hostName(t.publicURL), RelayURL: t.publicURL, Owner: func(key string) bool { return key == t.Policy().Owner }})
669 if err != nil {
670 return nil, true, err
671 }
672 value, err := s.Execute(ctx, actor, method, params)
673 return value, true, err
674}
675
676func (t *Tenant) listBlobs(ctx context.Context, params []json.RawMessage) (any, bool, error) {
677 limit := 100
678 if len(params) > 0 {
679 _ = json.Unmarshal(params[0], &limit)
680 }
681 if limit < 1 {
682 limit = 1
683 }
684 if limit > 500 {
685 limit = 500
686 }
687 rows, err := t.store.DB().QueryContext(ctx, `SELECT sha256,size,type,uploader,uploaded FROM blobs ORDER BY uploaded DESC LIMIT ?`, limit)
688 if err != nil {
689 return nil, true, err
690 }
691 defer rows.Close()
692 result := []map[string]any{}
693 for rows.Next() {
694 var sha, typ, uploader string
695 var size, uploaded int64
696 if err := rows.Scan(&sha, &size, &typ, &uploader, &uploaded); err != nil {
697 return nil, true, err
698 }
699 result = append(result, map[string]any{"sha256": sha, "size": size, "type": typ, "uploaded": uploaded, "uploader": uploader, "url": "/" + sha})
700 }
701 return result, true, rows.Err()
702}
703
704func (t *Tenant) deleteBlob(ctx context.Context, params []json.RawMessage) (any, bool, error) {
705 if t.blobs == nil || len(params) != 1 {
706 return nil, true, errors.New("invalid: sha256 required")
707 }
708 var sha string
709 if err := json.Unmarshal(params[0], &sha); err != nil {
710 return nil, true, errors.New("invalid: sha256 required")
711 }
712 return true, true, t.blobs.Delete(ctx, sha)
713}
714
715func (t *Tenant) listSites(ctx context.Context) (any, bool, error) {
716 rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{sites.KindSite, sites.KindNamedSite, sites.KindSiteSnapshot}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
717 if err != nil {
718 return nil, true, err
719 }
720 result := make([]map[string]any, 0, len(rows.Events))
721 for _, e := range rows.Events {
722 label := sites.SiteLabel(e)
723 if label == "" {
724 continue
725 }
726 result = append(result, map[string]any{"id": e.ID, "kind": e.Kind, "author": e.PubKey, "label": label, "paths": len(sites.SitePaths(e))})
727 }
728 return result, true, nil
729}
730
731func (t *Tenant) deleteEvent(ctx context.Context, params []json.RawMessage) (any, bool, error) {
732 if len(params) != 1 {
733 return nil, true, errors.New("invalid: event id required")
734 }
735 var id string
736 if err := json.Unmarshal(params[0], &id); err != nil {
737 return nil, true, err
738 }
739 deleted, err := t.store.DeleteEvent(ctx, id)
740 return deleted, true, err
741}
742
743func (t *Tenant) recentEvents(ctx context.Context, params []json.RawMessage) (any, bool, error) {
744 limit := intParam(params, 0, 50, 500)
745 rows, err := t.store.Query(ctx, event.Filter{Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: limit})
746 if err != nil {
747 return nil, true, err
748 }
749 return rows.Events, true, nil
750}
751
752func (t *Tenant) searchEvents(ctx context.Context, params []json.RawMessage) (any, bool, error) {
753 query := stringParam(params, 0)
754 limit := intParam(params, 1, 50, 200)
755 rows, err := t.store.Query(ctx, event.Filter{Search: query, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: limit})
756 if err != nil {
757 return nil, true, err
758 }
759 return rows.Events, true, nil
760}
761
762func (t *Tenant) storageStats(ctx context.Context) (any, bool, error) {
763 stats, err := t.store.Stats(ctx)
764 return stats, true, err
765}
766
767func (t *Tenant) publishView(ctx context.Context, params []json.RawMessage) (any, bool, error) {
768 name := stringParam(params, 0)
769 if name == "" {
770 return nil, true, errors.New("invalid: view name required")
771 }
772 if err := t.records.MarkView(ctx, name, time.Now().Unix()); err != nil {
773 return nil, true, err
774 }
775 view, err := t.records.View(ctx, name, policy.Access{Owner: true}, time.Now().Unix())
776 return view, true, err
777}
778
779func (t *Tenant) gitStorage(ctx context.Context, params []json.RawMessage) (any, bool, error) {
780 if t.git == nil {
781 return nil, true, errors.New("unsupported: git storage is disabled")
782 }
783 if len(params) != 2 {
784 return nil, true, errors.New("invalid: repository owner and identifier required")
785 }
786 owner, identifier := stringParam(params, 0), stringParam(params, 1)
787 if len(owner) != 64 {
788 return nil, true, errors.New("invalid: repository owner")
789 }
790 if _, err := hex.DecodeString(owner); err != nil || identifier == "" || len(identifier) > 256 {
791 return nil, true, errors.New("invalid: repository owner and identifier")
792 }
793 result, err := t.git.StorageInventory(ctx, owner, identifier)
794 return result, true, err
795}
796
797func (t *Tenant) resetRules(ctx context.Context) (any, bool, error) {
798 if err := t.store.WithTx(ctx, func(tx *sql.Tx) error {
799 if _, err := tx.ExecContext(ctx, "DELETE FROM community_kind_rules"); err != nil {
800 return err
801 }
802 return nil
803 }); err != nil {
804 return nil, true, err
805 }
806 if _, err := t.setPolicy(ctx, map[string]json.RawMessage{
807 "blockedWords": json.RawMessage(`[]`),
808 "openKinds": json.RawMessage(`[]`),
809 }); err != nil {
810 return nil, true, err
811 }
812 return map[string]any{"reset": true}, true, nil
813}
814
815func intParam(params []json.RawMessage, index, fallback, max int) int {
816 if len(params) <= index {
817 return fallback
818 }
819 var value int
820 if json.Unmarshal(params[index], &value) != nil {
821 return fallback
822 }
823 if value < 1 {
824 return fallback
825 }
826 if value > max {
827 return max
828 }
829 return value
830}
831func stringParam(params []json.RawMessage, index int) string {
832 if len(params) <= index {
833 return ""
834 }
835 var value string
836 _ = json.Unmarshal(params[index], &value)
837 return strings.TrimSpace(value)
838}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs