| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "html/template" |
| 8 | "io" |
| 9 | "net" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "strings" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/FelineStateMachine/tinyrelay/internal/blob" |
| 16 | "github.com/FelineStateMachine/tinyrelay/internal/domains" |
| 17 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 18 | "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 19 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 20 | ) |
| 21 | |
| 22 | func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 23 | w.Header().Set("Access-Control-Allow-Origin" , "*" ) |
| 24 | w.Header().Set("Access-Control-Allow-Headers" , "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, X-SHA-256, X-Content-Length, X-Content-Type, Upload-Type, Upload-Length, Upload-Offset" ) |
| 25 | w.Header().Set("Access-Control-Allow-Methods" , "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" ) |
| 26 | w.Header().Set("Access-Control-Expose-Headers" , "Allow, X-Reason" ) |
| 27 | w.Header().Set("X-Content-Type-Options" , "nosniff" ) |
| 28 | if r.Method == http.MethodOptions { |
| 29 | if t.blobs != nil && t.Policy().Features.Files && blob.HandlesPath(r.URL.Path) { |
| 30 | t.blobs.Handler().ServeHTTP(w, r) |
| 31 | return |
| 32 | } |
| 33 | w.WriteHeader(http.StatusNoContent) |
| 34 | return |
| 35 | } |
| 36 | if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" { |
| 37 | t.healthHTTP(w, r) |
| 38 | return |
| 39 | } |
| 40 | if t.unclaimedSiteHost(r.Host) { |
| 41 | // A host under the site domain that no site claims must not fall |
| 42 | // through to the relay: signed requests would otherwise verify |
| 43 | // against a URL the relay does not own. |
| 44 | http.NotFound(w, r) |
| 45 | return |
| 46 | } |
| 47 | if strings.EqualFold(r.Header.Get("Upgrade" ), "websocket" ) { |
| 48 | t.router.HandleHTTP(w, r) |
| 49 | return |
| 50 | } |
| 51 | if isRoomStreamPath(r.URL.Path) { |
| 52 | t.roomStreamHTTP(w, r) |
| 53 | return |
| 54 | } |
| 55 | if r.URL.Path != "/backups/restore" && r.URL.Path != "/manage/jobs/status" { |
| 56 | ctx, done, err := t.beginOperation(r.Context()) |
| 57 | if err != nil { |
| 58 | http.Error(w, err.Error(), http.StatusServiceUnavailable) |
| 59 | return |
| 60 | } |
| 61 | defer done() |
| 62 | r = r.WithContext(ctx) |
| 63 | } |
| 64 | if t.tryDataHTTP(w, r) { |
| 65 | return |
| 66 | } |
| 67 | if r.URL.Path == "/session" || r.URL.Path == "/session/logout" { |
| 68 | t.sessionHTTP(w, r) |
| 69 | return |
| 70 | } |
| 71 | if strings.HasPrefix(r.URL.Path, "/push/" ) || r.URL.Path == "/inbox/seen" { |
| 72 | t.pushHTTP(w, r) |
| 73 | return |
| 74 | } |
| 75 | if r.URL.Path == "/chat/preferences" { |
| 76 | t.chatPreferencesHTTP(w, r) |
| 77 | return |
| 78 | } |
| 79 | if r.URL.Path == "/mcp" { |
| 80 | t.mcpHTTP(w, r) |
| 81 | return |
| 82 | } |
| 83 | if r.URL.Path == "/llms.txt" { |
| 84 | t.llmsHTTP(w, r) |
| 85 | return |
| 86 | } |
| 87 | if t.tryBrowseHTTP(w, r) { |
| 88 | return |
| 89 | } |
| 90 | if t.blobs != nil && t.roomAttachmentHTTP(w, r) { |
| 91 | return |
| 92 | } |
| 93 | if isViewArtifactPath(r.URL.Path) { |
| 94 | t.customViews.viewArtifactHTTP(w, r) |
| 95 | return |
| 96 | } |
| 97 | if t.sites != nil && t.sites.MatchesHost(r.Host) { |
| 98 | t.sites.Handler().ServeHTTP(w, r) |
| 99 | return |
| 100 | } |
| 101 | if t.git != nil && (strings.HasPrefix(r.URL.Path, "/npub1" ) || strings.HasPrefix(r.URL.Path, "/npub/" ) || strings.HasPrefix(r.URL.Path, "/prs/" )) { |
| 102 | p := t.Policy() |
| 103 | if !p.Features.Grasp || (strings.HasPrefix(r.URL.Path, "/prs/" ) && !p.Features.Grasp06) { |
| 104 | http.NotFound(w, r) |
| 105 | return |
| 106 | } |
| 107 | if p.Reads != "open" && !privatePolicy(p) { |
| 108 | http.Error(w, "restricted: public Git hosting requires open reads" , http.StatusForbidden) |
| 109 | return |
| 110 | } |
| 111 | if privatePolicy(p) && strings.TrimSpace(r.Header.Get("Authorization" )) == "" { |
| 112 | writeGRASP08Challenge(w) |
| 113 | return |
| 114 | } |
| 115 | if privatePolicy(p) { |
| 116 | proof, authErr := t.auth.VerifyGRASP08(r.Header.Get("Authorization" ), t.requestURL(r)) |
| 117 | if authErr != nil { |
| 118 | writeGRASP08Challenge(w) |
| 119 | return |
| 120 | } |
| 121 | if accessErr := t.requirePrivateAccess(r.Context(), proof.PubKey); accessErr != nil { |
| 122 | writeGRASP08Challenge(w) |
| 123 | return |
| 124 | } |
| 125 | r = privateGitRequest(r, proof) |
| 126 | } |
| 127 | t.git.ServeHTTP(w, r) |
| 128 | return |
| 129 | } |
| 130 | if t.blobs != nil && blob.HandlesPath(r.URL.Path) { |
| 131 | if !t.Policy().Features.Files { |
| 132 | http.NotFound(w, r) |
| 133 | return |
| 134 | } |
| 135 | t.blobs.Handler().ServeHTTP(w, r) |
| 136 | return |
| 137 | } |
| 138 | if r.Method == http.MethodPost && strings.Contains(r.Header.Get("Content-Type" ), "application/nostr+json+rpc" ) { |
| 139 | t.manageHTTP(w, r) |
| 140 | return |
| 141 | } |
| 142 | if r.Method == http.MethodPost && (r.URL.Path == "/events" || r.URL.Path == "/query" || r.URL.Path == "/count" ) { |
| 143 | t.bridge(w, r) |
| 144 | return |
| 145 | } |
| 146 | if r.URL.Path == "/.well-known/nostr.json" { |
| 147 | if _, exists := r.URL.Query()["path" ]; exists { |
| 148 | t.webAddress(w, r) |
| 149 | return |
| 150 | } |
| 151 | t.nip05(w, r) |
| 152 | return |
| 153 | } |
| 154 | if strings.Contains(r.Header.Get("Accept" ), "application/nostr+json" ) { |
| 155 | t.information(w, r) |
| 156 | return |
| 157 | } |
| 158 | if t.ui != nil { |
| 159 | t.ui.ServeHTTP(w, r) |
| 160 | return |
| 161 | } |
| 162 | if r.URL.Path == "/" { |
| 163 | t.home(w, r) |
| 164 | return |
| 165 | } |
| 166 | http.NotFound(w, r) |
| 167 | } |
| 168 | |
| 169 | func writeGRASP08Challenge(w http.ResponseWriter) { |
| 170 | w.Header().Set("WWW-Authenticate" , `Nostr method="GET"` ) |
| 171 | w.WriteHeader(http.StatusUnauthorized) |
| 172 | } |
| 173 | |
| 174 | func (t *Tenant) webAddress(w http.ResponseWriter, r *http.Request) { |
| 175 | actor, err := t.resolveUIActor(r) |
| 176 | if err != nil { |
| 177 | http.Error(w, err.Error(), 401) |
| 178 | return |
| 179 | } |
| 180 | keys := []string{} |
| 181 | if actor != "" { |
| 182 | keys = append(keys, actor) |
| 183 | } |
| 184 | session := relay.Session{PubKeys: keys, RelayURL: t.RelayURL()} |
| 185 | domains.WebAddressHandler(domains.AddressConfig{ |
| 186 | RelayURL: t.RelayURL(), |
| 187 | Authorized: func(req *http.Request) bool { |
| 188 | // resolveUIActor already verified and consumed the NIP-98 proof. |
| 189 | // Revalidating it here would reject every signed request as replay. |
| 190 | return req.Header.Get("Authorization" ) != "" && actor != "" |
| 191 | }, |
| 192 | ReadAllowed: func(r *http.Request) bool { |
| 193 | _, err := t.gate.Read(r.Context(), []event.Filter{{}}, session) |
| 194 | return err == nil |
| 195 | }, |
| 196 | Lookup: func(ctx context.Context, path string) (map[string]any, bool, error) { |
| 197 | filter, ok := AddressFilterForPath(path, t.Policy().Owner, t.records.PublicKey()) |
| 198 | if !ok { |
| 199 | return nil, false, nil |
| 200 | } |
| 201 | raw, err := json.Marshal(filter) |
| 202 | if err != nil { |
| 203 | return nil, false, err |
| 204 | } |
| 205 | f, err := event.ParseFilter(raw) |
| 206 | if err != nil { |
| 207 | return nil, false, err |
| 208 | } |
| 209 | rows, err := t.Query(ctx, []event.Filter{f}, session) |
| 210 | return filter, len(rows) > 0, err |
| 211 | }, |
| 212 | }).ServeHTTP(w, r) |
| 213 | } |
| 214 | |
| 215 | func writeJSON(w http.ResponseWriter, status int, value any) { |
| 216 | raw, err := json.Marshal(value) |
| 217 | if err != nil { |
| 218 | http.Error(w, "could not encode response" , 500) |
| 219 | return |
| 220 | } |
| 221 | w.Header().Set("Content-Type" , "application/json; charset=utf-8" ) |
| 222 | w.WriteHeader(status) |
| 223 | if _, err := w.Write(raw); err != nil { |
| 224 | return |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | func (t *Tenant) requestBody(w http.ResponseWriter, r *http.Request) ([]byte, error) { |
| 229 | var reader io.Reader = r.Body |
| 230 | if t.app.cfg.MaxMessageBytes > 0 { |
| 231 | reader = http.MaxBytesReader(w, r.Body, t.app.cfg.MaxMessageBytes) |
| 232 | } |
| 233 | body, err := io.ReadAll(reader) |
| 234 | if err != nil { |
| 235 | return nil, err |
| 236 | } |
| 237 | return body, nil |
| 238 | } |
| 239 | |
| 240 | func (t *Tenant) session(r *http.Request, body []byte) (relay.Session, error) { |
| 241 | ip, _, err := net.SplitHostPort(r.RemoteAddr) |
| 242 | if err != nil { |
| 243 | ip = r.RemoteAddr |
| 244 | } |
| 245 | s := relay.Session{RelayURL: t.RelayURL(), RemoteIP: ip} |
| 246 | url := t.requestURL(r) |
| 247 | e, err := t.auth.VerifyNIP98(r.Header.Get("Authorization" ), url, r.Method, string(body)) |
| 248 | if err != nil { |
| 249 | return s, err |
| 250 | } |
| 251 | s.PubKeys = []string{e.PubKey} |
| 252 | return s, nil |
| 253 | } |
| 254 | |
| 255 | // cookieReadSession lets a signed-in browser read through the query bridge |
| 256 | // without a signature, so pages do not prompt the signer just to show data. |
| 257 | // The Origin check keeps another site from reading with a visitor's cookie; |
| 258 | // publishing still needs a signed event. |
| 259 | func (t *Tenant) cookieReadSession(r *http.Request, s relay.Session) (relay.Session, error) { |
| 260 | base, err := url.Parse(t.requestURL(r)) |
| 261 | if err != nil || r.Header.Get("Origin" ) != base.Scheme+"://" +base.Host { |
| 262 | return s, errors.New("auth-required: sign this request" ) |
| 263 | } |
| 264 | actor, err := t.cookieActor(r) |
| 265 | if err != nil { |
| 266 | return s, err |
| 267 | } |
| 268 | if actor == "" { |
| 269 | return s, errors.New("auth-required: sign this request" ) |
| 270 | } |
| 271 | s.PubKeys = []string{actor} |
| 272 | return s, nil |
| 273 | } |
| 274 | |
| 275 | func (t *Tenant) bridge(w http.ResponseWriter, r *http.Request) { |
| 276 | body, err := t.requestBody(w, r) |
| 277 | if err != nil { |
| 278 | writeJSON(w, 400, map[string]string{"error" : err.Error()}) |
| 279 | return |
| 280 | } |
| 281 | s, err := t.session(r, body) |
| 282 | if err != nil && r.URL.Path != "/events" && r.Header.Get("Authorization" ) == "" { |
| 283 | s, err = t.cookieReadSession(r, s) |
| 284 | } |
| 285 | if err != nil { |
| 286 | writeJSON(w, 401, map[string]string{"error" : err.Error()}) |
| 287 | return |
| 288 | } |
| 289 | if r.URL.Path == "/events" { |
| 290 | t.httpPublish(w, r, s, body) |
| 291 | return |
| 292 | } |
| 293 | var raw []json.RawMessage |
| 294 | if err := json.Unmarshal(body, &raw); err != nil || len(raw) == 0 { |
| 295 | writeJSON(w, 400, map[string]string{"error" : "invalid: body must be a non-empty array of filters" }) |
| 296 | return |
| 297 | } |
| 298 | filters := make([]event.Filter, len(raw)) |
| 299 | for i, b := range raw { |
| 300 | f, err := event.ParseFilter(b) |
| 301 | if err != nil { |
| 302 | writeJSON(w, 400, map[string]string{"error" : err.Error()}) |
| 303 | return |
| 304 | } |
| 305 | filters[i] = f |
| 306 | } |
| 307 | var result any |
| 308 | if r.URL.Path == "/count" { |
| 309 | result, err = t.Count(r.Context(), filters, s) |
| 310 | } else { |
| 311 | result, err = t.Query(r.Context(), filters, s) |
| 312 | } |
| 313 | if err != nil { |
| 314 | writeJSON(w, statusFor(err), map[string]string{"error" : err.Error()}) |
| 315 | return |
| 316 | } |
| 317 | writeJSON(w, 200, result) |
| 318 | } |
| 319 | |
| 320 | func (t *Tenant) httpPublish(w http.ResponseWriter, r *http.Request, s relay.Session, body []byte) { |
| 321 | e, err := event.Parse(body) |
| 322 | if err != nil { |
| 323 | writeJSON(w, 400, map[string]string{"error" : err.Error()}) |
| 324 | return |
| 325 | } |
| 326 | message, err := t.router.Publish(r.Context(), e, s) |
| 327 | status := 200 |
| 328 | if err != nil { |
| 329 | message = err.Error() |
| 330 | status = 400 |
| 331 | } |
| 332 | writeJSON(w, status, map[string]any{"event_id" : e.ID, "accepted" : err == nil, "message" : message}) |
| 333 | } |
| 334 | |
| 335 | func statusFor(err error) int { |
| 336 | if strings.HasPrefix(err.Error(), "auth-required:" ) { |
| 337 | return 401 |
| 338 | } |
| 339 | if strings.HasPrefix(err.Error(), "restricted:" ) || strings.HasPrefix(err.Error(), "blocked:" ) { |
| 340 | return 403 |
| 341 | } |
| 342 | return 400 |
| 343 | } |
| 344 | |
| 345 | func (t *Tenant) information(w http.ResponseWriter, r *http.Request) { |
| 346 | p := t.Policy() |
| 347 | nips := []int{} |
| 348 | var agents *Capability |
| 349 | for _, capability := range t.Capabilities(nil) { |
| 350 | if capability.Status != "enabled" { |
| 351 | continue |
| 352 | } |
| 353 | if capability.ID == "agents" { |
| 354 | entry := capability |
| 355 | agents = &entry |
| 356 | } |
| 357 | if number, ok := capabilityNIPNumber(capability.ID); ok { |
| 358 | nips = append(nips, number) |
| 359 | } |
| 360 | } |
| 361 | name := p.Name |
| 362 | if name == "" { |
| 363 | name = t.meta.Name |
| 364 | } |
| 365 | limits := map[string]any{"max_subid_length" : 64, "auth_required" : p.Reads != "open" , "payment_required" : false, "restricted_writes" : p.Writes != "open" } |
| 366 | if t.app.cfg.MaxMessageBytes > 0 { |
| 367 | limits["max_message_length" ] = t.app.cfg.MaxMessageBytes |
| 368 | } |
| 369 | if p.MaxFuture > 0 { |
| 370 | limits["created_at_upper_limit" ] = p.MaxFuture |
| 371 | } |
| 372 | if p.MinPow > 0 { |
| 373 | limits["min_pow_difficulty" ] = p.MinPow |
| 374 | } |
| 375 | info := map[string]any{"name" : name, "description" : p.Description, "pubkey" : p.Owner, "supported_nips" : nips, "software" : "https://github.com/FelineStateMachine/tinyrelay" , "version" : t.app.cfg.Version, "self_url" : t.RelayURL(), "limitation" : limits} |
| 376 | if privatePolicy(p) { |
| 377 | // GRASP-08 discovery is intentionally sparse. Clients need the service |
| 378 | // identity and protocol markers; tenant presentation metadata stays |
| 379 | // behind authenticated reads. |
| 380 | info = t.privateNIP11() |
| 381 | info["supported_nips" ] = nips |
| 382 | info["software" ] = "https://github.com/FelineStateMachine/tinyrelay" |
| 383 | info["version" ] = t.app.cfg.Version |
| 384 | info["self_url" ] = t.RelayURL() |
| 385 | info["limitation" ] = limits |
| 386 | } |
| 387 | if t.records != nil { |
| 388 | info["self" ] = t.records.PublicKey() |
| 389 | } |
| 390 | if p.Features.Grasp && t.git != nil { |
| 391 | info["supported_grasps" ] = t.git.SupportedGRASPs() |
| 392 | } |
| 393 | if agents != nil { |
| 394 | info["agents" ] = *agents |
| 395 | } |
| 396 | if p.Features.Sites.Enabled && !privatePolicy(p) { |
| 397 | base, _ := url.Parse(t.publicURL) |
| 398 | domain := t.siteDomain() |
| 399 | if base.Port() != "" { |
| 400 | domain += ":" + base.Port() |
| 401 | } |
| 402 | info["nsites" ] = map[string]any{"host" : t.siteDomain(), "kinds" : []int{15128, 35128, 5128}, "root" : base.Scheme + "://<npub>." + domain, "named" : base.Scheme + "://<pubkeyB36><dTag>." + domain, "snapshot" : base.Scheme + "://v<snapshotIdB36>." + domain} |
| 403 | } |
| 404 | for key, value := range map[string]string{"icon" : p.Icon, "banner" : p.Banner, "contact" : p.Contact, "posting_policy" : p.PostingPolicy, "privacy_policy" : p.PrivacyPolicy} { |
| 405 | if privatePolicy(p) { |
| 406 | break |
| 407 | } |
| 408 | if value != "" { |
| 409 | info[key] = value |
| 410 | } |
| 411 | } |
| 412 | if p.JoinTerms != "" && !privatePolicy(p) { |
| 413 | info["terms_of_service" ] = t.publicURL + "/terms" |
| 414 | } |
| 415 | if len(p.Tags) > 0 && !privatePolicy(p) { |
| 416 | info["tags" ] = p.Tags |
| 417 | } |
| 418 | if len(p.LanguageTags) > 0 && !privatePolicy(p) { |
| 419 | info["language_tags" ] = p.LanguageTags |
| 420 | } |
| 421 | if len(p.RelayCountries) > 0 && !privatePolicy(p) { |
| 422 | info["relay_countries" ] = p.RelayCountries |
| 423 | } |
| 424 | raw, err := json.Marshal(info) |
| 425 | if err != nil { |
| 426 | http.Error(w, err.Error(), 500) |
| 427 | return |
| 428 | } |
| 429 | w.Header().Set("Content-Type" , "application/nostr+json" ) |
| 430 | if _, err := w.Write(raw); err != nil { |
| 431 | return |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | func (t *Tenant) nip05(w http.ResponseWriter, r *http.Request) { |
| 436 | if !t.Policy().Features.Names { |
| 437 | http.NotFound(w, r) |
| 438 | return |
| 439 | } |
| 440 | if privatePolicy(t.Policy()) { |
| 441 | actor, err := t.resolveUIActor(r) |
| 442 | if err != nil || actor == "" { |
| 443 | http.Error(w, "auth-required: private service metadata requires NIP-98" , http.StatusUnauthorized) |
| 444 | return |
| 445 | } |
| 446 | if err := t.requirePrivateAccess(r.Context(), actor); err != nil { |
| 447 | http.Error(w, err.Error(), http.StatusForbidden) |
| 448 | return |
| 449 | } |
| 450 | } |
| 451 | result, err := t.community.NIP05(r.Context(), r.URL.Query().Get("name" ), t.RelayURL()) |
| 452 | if err != nil { |
| 453 | writeJSON(w, 404, map[string]string{"error" : "name not found" }) |
| 454 | return |
| 455 | } |
| 456 | writeJSON(w, 200, result) |
| 457 | } |
| 458 | |
| 459 | func (t *Tenant) home(w http.ResponseWriter, r *http.Request) { |
| 460 | tmpl, err := template.New("home" ).Parse(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>{{.Name}}</title><h1>{{.Name}}</h1><p>{{.Description}}</p><p>Relay: <code>{{.URL}}</code></p><p><a href="./people">People</a> | <a href="./manage">Manage relay</a></p></html>` ) |
| 461 | if err != nil { |
| 462 | http.Error(w, "page unavailable" , 500) |
| 463 | return |
| 464 | } |
| 465 | p := t.Policy() |
| 466 | name := p.Name |
| 467 | if name == "" { |
| 468 | name = t.meta.Name |
| 469 | } |
| 470 | w.Header().Set("Content-Type" , "text/html; charset=utf-8" ) |
| 471 | if err := tmpl.Execute(w, map[string]string{"Name" : name, "Description" : p.Description, "URL" : t.RelayURL()}); err != nil { |
| 472 | t.app.telemetry.Logger().Error("render page" , "error" , err) |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | func (t *Tenant) manageHTTP(w http.ResponseWriter, r *http.Request) { |
| 477 | body, err := t.requestBody(w, r) |
| 478 | if err != nil { |
| 479 | writeJSON(w, 400, map[string]string{"error" : err.Error()}) |
| 480 | return |
| 481 | } |
| 482 | var call struct { |
| 483 | Method string `json:"method"` |
| 484 | Params []json.RawMessage `json:"params"` |
| 485 | } |
| 486 | if err := json.Unmarshal(body, &call); err != nil { |
| 487 | writeJSON(w, 400, map[string]string{"error" : "invalid: malformed management request" }) |
| 488 | return |
| 489 | } |
| 490 | actor := "" |
| 491 | if call.Method != "supportedmethods" { |
| 492 | s, err := t.session(r, body) |
| 493 | if err != nil { |
| 494 | writeJSON(w, 401, map[string]string{"error" : err.Error()}) |
| 495 | return |
| 496 | } |
| 497 | actor = s.PubKeys[0] |
| 498 | } |
| 499 | result, err := t.Execute(r.Context(), actor, call.Method, call.Params) |
| 500 | if err != nil { |
| 501 | writeJSON(w, statusFor(err), map[string]string{"error" : err.Error()}) |
| 502 | return |
| 503 | } |
| 504 | writeJSON(w, 200, map[string]any{"result" : result}) |
| 505 | } |
| 506 | |
| 507 | // Execute dispatches a management method with positional JSON parameters. |
| 508 | // actor is the authenticated public key, or empty for an anonymous caller. |
| 509 | // The transport establishes actor identity; method handlers apply current |
| 510 | // membership and resource permissions. Results retain each method's wire shape. |
| 511 | func (t *Tenant) Execute(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) { |
| 512 | ctx, done, admissionErr := t.beginOperation(ctx) |
| 513 | if admissionErr != nil { |
| 514 | return nil, admissionErr |
| 515 | } |
| 516 | defer done() |
| 517 | if method == "supportedmethods" { |
| 518 | methods := append([]string{"supportedmethods" , "getpolicy" , "setpolicy" , "stats" , "listlisthistory" , "restorelist" }, t.community.Methods()...) |
| 519 | methods = append(methods, "successionstatus" , "setsuccession" , "clearsuccession" , "listpins" , "pinevent" , "unpinevent" , "listviews" , "notifytest" , "notifystatus" ) |
| 520 | if t.config != nil { |
| 521 | methods = append(methods, "exportconfig" , "importconfig" , "planconfig" , "applypreset" , "listpresets" ) |
| 522 | } |
| 523 | if t.replication != nil { |
| 524 | methods = append(methods, t.replication.Methods()...) |
| 525 | } |
| 526 | methods = append(methods, ManagementMethods()...) |
| 527 | seen := make(map[string]bool) |
| 528 | result := []string{} |
| 529 | for _, method := range methods { |
| 530 | if !seen[method] { |
| 531 | seen[method] = true |
| 532 | result = append(result, method) |
| 533 | } |
| 534 | } |
| 535 | return result, nil |
| 536 | } |
| 537 | role, err := t.community.Role(ctx, actor) |
| 538 | if err != nil { |
| 539 | return nil, err |
| 540 | } |
| 541 | if result, handled, err := t.executeManagement(ctx, actor, method, params); handled { |
| 542 | return result, err |
| 543 | } |
| 544 | if containsString(t.community.Methods(), method) { |
| 545 | return t.executeCommunity(ctx, actor, method, params) |
| 546 | } |
| 547 | if method == "listlisthistory" { |
| 548 | return t.store.ListHistory(ctx, actor, time.Now().Unix()) |
| 549 | } |
| 550 | if method == "restorelist" { |
| 551 | if len(params) == 0 { |
| 552 | return nil, errors.New("invalid: event id required" ) |
| 553 | } |
| 554 | var id string |
| 555 | if err := json.Unmarshal(params[0], &id); err != nil { |
| 556 | return nil, err |
| 557 | } |
| 558 | return t.store.RestoreHistory(ctx, storage.RestoreOptions{Owner: actor, EventID: id, Now: time.Now().Unix()}) |
| 559 | } |
| 560 | if role != "owner" && role != "moderator" { |
| 561 | return nil, errors.New("restricted: owner or moderator required" ) |
| 562 | } |
| 563 | if t.records != nil && containsString([]string{"successionstatus" , "setsuccession" , "clearsuccession" , "listpins" , "pinevent" , "unpinevent" , "listviews" , "notifytest" , "notifystatus" }, method) { |
| 564 | return t.records.Execute(ctx, actor, method, params) |
| 565 | } |
| 566 | switch method { |
| 567 | case "getpolicy" : |
| 568 | return t.Policy(), nil |
| 569 | case "setpolicy" : |
| 570 | if role != "owner" { |
| 571 | return nil, errors.New("restricted: only the owner sets policy" ) |
| 572 | } |
| 573 | if len(params) == 0 { |
| 574 | return nil, errors.New("invalid: policy patch required" ) |
| 575 | } |
| 576 | var patch map[string]json.RawMessage |
| 577 | if err := json.Unmarshal(params[0], &patch); err != nil { |
| 578 | return nil, err |
| 579 | } |
| 580 | delete(patch, "owner" ) |
| 581 | return t.setPolicy(ctx, patch) |
| 582 | case "stats" : |
| 583 | return t.store.Stats(ctx) |
| 584 | default: |
| 585 | if t.config != nil && containsString([]string{"exportconfig" , "importconfig" , "planconfig" , "applypreset" , "listpresets" }, method) { |
| 586 | if role != "owner" { |
| 587 | return nil, errors.New("restricted: only the owner manages configuration" ) |
| 588 | } |
| 589 | return t.config.Execute(ctx, method, params) |
| 590 | } |
| 591 | if t.replication != nil && containsString(t.replication.Methods(), method) { |
| 592 | if role != "owner" { |
| 593 | return nil, errors.New("restricted: only the owner manages replication" ) |
| 594 | } |
| 595 | return t.replication.ExecuteRaw(ctx, method, params) |
| 596 | } |
| 597 | return t.executeCommunity(ctx, actor, method, params) |
| 598 | } |
| 599 | } |
| 600 | |
| 601 | func (t *Tenant) executeCommunity(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) { |
| 602 | if !communityACLMutation(method) { |
| 603 | return t.community.Execute(ctx, actor, method, params) |
| 604 | } |
| 605 | result, err := t.router.ApplyACLChange(ctx, func(changeCtx context.Context) (any, error) { |
| 606 | return t.community.Execute(changeCtx, actor, method, params) |
| 607 | }, "blocked: relay access policy changed; subscribe again" ) |
| 608 | return result, err |
| 609 | } |
| 610 | |
| 611 | func communityACLMutation(method string) bool { |
| 612 | switch method { |
| 613 | case "setmember" , "allowpubkey" , "removemember" , "unrulepubkey" , "removesubtree" , "approvejoin" , "banpubkey" , "banevent" , "allowevent" , "blockip" , "unblockip" , "resolvereport" , "allowkind" , "disallowkind" , "unrulekind" , "setblockedwords" : |
| 614 | return true |
| 615 | default: |
| 616 | return false |
| 617 | } |
| 618 | } |
| 619 | |
| 620 | func containsString(values []string, wanted string) bool { |
| 621 | for _, value := range values { |
| 622 | if value == wanted { |
| 623 | return true |
| 624 | } |
| 625 | } |
| 626 | return false |
| 627 | } |
| 628 | |
| 629 | // unclaimedSiteHost reports whether host is a subdomain of the site domain |
| 630 | // that neither a hosted site nor a custom host claims. |
| 631 | func (t *Tenant) unclaimedSiteHost(host string) bool { |
| 632 | host = strings.ToLower(strings.Split(host, ":" )[0]) |
| 633 | domain := strings.ToLower(t.siteDomain()) |
| 634 | if domain == "" || host == domain || !strings.HasSuffix(host, "." +domain) { |
| 635 | return false |
| 636 | } |
| 637 | if t.sites != nil && t.sites.MatchesHost(host) { |
| 638 | return false |
| 639 | } |
| 640 | for _, custom := range t.Policy().CustomHosts { |
| 641 | if strings.EqualFold(custom.Host, host) { |
| 642 | return false |
| 643 | } |
| 644 | } |
| 645 | return true |
| 646 | } |