clone

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

feat/standalone-relay
tinyrelay / internal / daemon / management.go
management.go 29.2K
1package daemon23// This file contains the second half of the management registry. Keeping the4// adapters here lets HTTP, the plain HTML UI, and future CLI callers share the5// same source-shaped responses while the core Tenant type remains focused on6// protocol admission.78import (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"2021 "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)3132// executeManagement handles methods that are not owned by community,33// records, configport, or replication. handled is false when the caller34// 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, err39 }40 if roomBrowseMethod(method) {41 result, err = t.executeRoomBrowse(ctx, actor, method, params)42 return result, true, err43 }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 rule49 // lives with the methods.50 result, err = t.callbackExecute(ctx, actor, method, params)51 return result, true, err52 }53 if customViewMethod(method) {54 result, err = t.customViews.customViewExecute(ctx, actor, method, params)55 return result, true, err56 }57 if !managementAdapterMethod(method) {58 return nil, false, nil59 }60 role, roleErr := t.community.Role(ctx, actor)61 if roleErr != nil {62 return nil, true, roleErr63 }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, nil70 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, nil104 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}116117func 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 true121 }122 }123 return false124}125126// PublicMethods are backend calls used by the unauthenticated HTML pages. They127// are intentionally separate from the NIP-86 management methods so a public128// page cannot accidentally gain access to privileged search or export APIs.129func PublicMethods() []string {130 return []string{"queryevents", "eventdetail", "feed", "listview", "claiminvite", "connections"}131}132133func publicManagementMethod(method string) bool {134 for _, name := range PublicMethods() {135 if method == name {136 return true137 }138 }139 return false140}141142// ManagementMethods is the source-compatible NIP-86 registry. The hosted143// 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}147148func managementRoleAllows(method, role string) bool {149 if role == "owner" {150 return true151 }152 if role != "moderator" {153 return false154 }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 true158 }159 }160 return false161}162163func roleName(role string) string {164 if role == "" {165 return "guest"166 }167 return role168}169170func (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, err185 }186 return map[string]any{"claimed": true, "code": code}, true, nil187 }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.Filter195 var err error196 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, err210 }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, err218 }219 if method == "eventdetail" {220 if len(rows) == 0 {221 return nil, true, sql.ErrNoRows222 }223 return rows[0], true, nil224 }225 return rows, true, nil226}227228func publicFilter(params []json.RawMessage) (event.Filter, error) {229 if len(params) == 0 {230 return event.Filter{Tags: map[string][]string{}, Limit: mgmtIntPtr(50)}, nil231 }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, nil243}244245func (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, err253 }254 owner := role == "owner"255 member := role == "member" || role == "moderator" || owner256 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, err263 }264 return view.Event, true, nil265}266267func mgmtIntPtr(value int) *int { return &value }268269func (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}273274func (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().Owner283 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, err289 }290 if err := t.applyPolicy(ctx, nextPolicy); err != nil {291 return nil, true, err292 }293 return map[string]any{"owner": next, "previousOwner": current}, true, nil294}295296// agentExecute serves the agent kill switch and inventory. The community297// service records each change in the audit log; this adapter only shapes298// 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, err305 case "pauseallagents", "resumeallagents":306 changed, err := t.community.SetAllAgentsPaused(ctx, actor, method == "pauseallagents")307 if err != nil {308 return nil, true, err309 }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, nil312 }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.AgentGrant321 var err error322 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, err330 }331 return grant, true, nil332}333334func (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, err341 }342 return map[string]any{"deleted": true, "name": t.meta.Name}, true, nil343}344345func (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 = actor364 }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, err371 }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, err376 }377 filter := event.Filter{Tags: map[string][]string{}}378 if options.Filter != nil {379 filter = *options.Filter380 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.Kinds386 }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, err390 }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, err398 }399 if options.People {400 if err := copyForkPeople(ctx, t.store, target.store, options.Owner); err != nil {401 return nil, true, err402 }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, nil405}406407func (a *App) forkRelayURL(source *Tenant, name string) string {408 base := strings.TrimSuffix(a.cfg.PublicURL, "/")409 if base != "" {410 return base + "/r/" + name411 }412 u, err := url.Parse(source.publicURL)413 if err != nil {414 return strings.TrimSuffix(source.publicURL, "/") + "/r/" + name415 }416 prefix := "/r/" + source.meta.Name417 if strings.HasSuffix(u.Path, prefix) {418 u.Path = strings.TrimSuffix(u.Path, prefix)419 }420 u.Path = strings.TrimSuffix(u.Path, "/") + "/r/" + name421 return strings.TrimSuffix(u.String(), "/")422}423424func 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 string428 insert string429 query string430 }{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 any441 if err := rows.Scan(&first, &second); err != nil {442 _ = rows.Close()443 return err444 }445 if _, err := tx.ExecContext(ctx, table.insert, first, second); err != nil {446 _ = rows.Close()447 return err448 }449 }450 if err := rows.Err(); err != nil {451 _ = rows.Close()452 return err453 }454 _ = rows.Close()455 }456 return nil457 })458}459460func 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 string469 var createdAt, keepDays int64470 if err := rows.Scan(&pubkey, &name, &note, &role, &invitedBy, &via, &createdAt, &keepDays); err != nil {471 return err472 }473 if pubkey == owner {474 continue475 }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 err478 }479 }480 return rows.Err()481 })482}483484// AddressFilterForPath turns the plain HTML address routes into storage485// filters. It is also used by NIP-AD discovery handlers to keep route and486// 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, false492 }493 value, err := url.PathUnescape(parts[1])494 if err != nil || strings.ContainsAny(value, "/\\") {495 return nil, false496 }497 switch parts[0] {498 case "e":499 if len(value) != 64 {500 return nil, false501 }502 if _, err := hex.DecodeString(value); err != nil {503 return nil, false504 }505 return map[string]any{"ids": []string{value}, "kinds": []int{1, 30023}, "limit": 1}, true506 case "a":507 if author == "" {508 return nil, false509 }510 return map[string]any{"authors": []string{author}, "kinds": []int{30023}, "#d": []string{value}, "limit": 1}, true511 case "view":512 if identity == "" {513 return nil, false514 }515 return map[string]any{"authors": []string{identity}, "kinds": []int{30078}, "#d": []string{"bind.ws/view/" + value}, "limit": 1}, true516 default:517 return nil, false518 }519}520521func (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 result529}530531func featureEnabled(p policy.Policy, name string) bool {532 switch name {533 case "files":534 return p.Features.Files535 case "sites":536 return p.Features.Sites.Enabled537 case "grasp":538 return p.Features.Grasp539 case "marmot":540 return p.Features.Marmot541 case "search":542 return p.Features.Search != "off"543 default:544 return false545 }546}547548// visibleConnections lists configured connections the viewer may see, each549// 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. Connections552// 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, err557 }558 role := ""559 if strings.TrimSpace(actor) != "" {560 if role, err = t.community.Role(ctx, actor); err != nil {561 return nil, true, err562 }563 }564 catalog := map[string]templates.Connection{}565 for _, c := range templates.Connections() {566 catalog[c.Name] = c567 }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 continue578 }579 visibility, _ := row["visibility"].(string)580 if visibility == "" {581 visibility = c.Visibility582 }583 switch visibility {584 case "public":585 case "members", "member", "auth":586 if role != "member" && role != "moderator" && role != "owner" {587 continue588 }589 default:590 if role != "owner" {591 continue592 }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, nil597}598599func (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 string608 if err := rows.Scan(&raw); err != nil {609 return nil, true, err610 }611 var value map[string]any612 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}619620func (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]any625 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 err631 }632 for i, value := range values {633 raw, err := json.Marshal(value)634 if err != nil {635 return err636 }637 if _, err := tx.ExecContext(ctx, `INSERT INTO config_connections(position,value) VALUES(?,?)`, i, raw); err != nil {638 return err639 }640 }641 return nil642 }); err != nil {643 return nil, true, fmt.Errorf("save connections: %w", err)644 }645 return values, true, nil646}647648func (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 string653 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 := 2000657 if key == "name" {658 limit = 200659 }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, err665}666667func (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, err671 }672 value, err := s.Execute(ctx, actor, method, params)673 return value, true, err674}675676func (t *Tenant) listBlobs(ctx context.Context, params []json.RawMessage) (any, bool, error) {677 limit := 100678 if len(params) > 0 {679 _ = json.Unmarshal(params[0], &limit)680 }681 if limit < 1 {682 limit = 1683 }684 if limit > 500 {685 limit = 500686 }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, err690 }691 defer rows.Close()692 result := []map[string]any{}693 for rows.Next() {694 var sha, typ, uploader string695 var size, uploaded int64696 if err := rows.Scan(&sha, &size, &typ, &uploader, &uploaded); err != nil {697 return nil, true, err698 }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}703704func (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 string709 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}714715func (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, err719 }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 continue725 }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, nil729}730731func (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 string736 if err := json.Unmarshal(params[0], &id); err != nil {737 return nil, true, err738 }739 deleted, err := t.store.DeleteEvent(ctx, id)740 return deleted, true, err741}742743func (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, err748 }749 return rows.Events, true, nil750}751752func (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, err758 }759 return rows.Events, true, nil760}761762func (t *Tenant) storageStats(ctx context.Context) (any, bool, error) {763 stats, err := t.store.Stats(ctx)764 return stats, true, err765}766767func (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, err774 }775 view, err := t.records.View(ctx, name, policy.Access{Owner: true}, time.Now().Unix())776 return view, true, err777}778779func (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, err795}796797func (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 err801 }802 return nil803 }); err != nil {804 return nil, true, err805 }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, err811 }812 return map[string]any{"reset": true}, true, nil813}814815func intParam(params []json.RawMessage, index, fallback, max int) int {816 if len(params) <= index {817 return fallback818 }819 var value int820 if json.Unmarshal(params[index], &value) != nil {821 return fallback822 }823 if value < 1 {824 return fallback825 }826 if value > max {827 return max828 }829 return value830}831func stringParam(params []json.RawMessage, index int) string {832 if len(params) <= index {833 return ""834 }835 var value string836 _ = json.Unmarshal(params[index], &value)837 return strings.TrimSpace(value)838}