|
|
|
internal/daemon/management.go raw
| 1 | package 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 | | | 8 | import ( | | 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. | | 35 | func (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 | | | 117 | func 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. | | 129 | func PublicMethods() []string { | | 130 | return []string{"queryevents" , "eventdetail" , "feed" , "listview" , "claiminvite" , "connections" } | | 131 | } | | 132 | | | 133 | func 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. | | 144 | func 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 | | | 148 | func 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 | | | 163 | func roleName(role string) string { | | 164 | if role == "" { | | 165 | return "guest" | | 166 | } | | 167 | return role | | 168 | } | | 169 | | | 170 | func (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 | | | 228 | func 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 | | | 245 | func (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 | | | 267 | func mgmtIntPtr(value int) *int { return &value } | | 268 | | | 269 | func (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 | | | 274 | func (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. | | 299 | func (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 | | | 334 | func (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 | | | 345 | func (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 | | | 407 | func (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 | | | 424 | func 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 | | | 460 | func 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, ¬e, &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. | | 487 | func 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 | | | 521 | func (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 | | | 531 | func 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. | | 553 | func (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 | | | 599 | func (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 | | | 620 | func (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 | | | 648 | func (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 | | | 667 | func (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 | | | 676 | func (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 | | | 704 | func (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 | | | 715 | func (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 | | | 731 | func (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 | | | 743 | func (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 | | | 752 | func (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 | | | 762 | func (t *Tenant) storageStats(ctx context.Context) (any, bool, error) { | | 763 | stats, err := t.store.Stats(ctx) | | 764 | return stats, true, err | | 765 | } | | 766 | | | 767 | func (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 | | | 779 | func (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 | | | 797 | func (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 | | | 815 | func 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 | } | | 831 | func 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| owner | c01503e8035f |
|---|
| visibility | public |
|---|
| head | d730679639bf |
|---|
Maintainers- c01503e8035f owner
- ba1b5beed5b5 agent hermes
Clonehttps://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git
|