clone

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

feat/ui-updates
tinyrelay / internal / daemon / http.go
http.go 19.7K
1package daemon23import (4 "context"5 "encoding/json"6 "errors"7 "html/template"8 "io"9 "net"10 "net/http"11 "net/url"12 "strings"13 "time"1415 "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)2122func (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 return32 }33 w.WriteHeader(http.StatusNoContent)34 return35 }36 if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {37 t.healthHTTP(w, r)38 return39 }40 if t.unclaimedSiteHost(r.Host) {41 // A host under the site domain that no site claims must not fall42 // through to the relay: signed requests would otherwise verify43 // against a URL the relay does not own.44 http.NotFound(w, r)45 return46 }47 if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {48 t.router.ServeHTTP(w, r)49 return50 }51 if isRoomStreamPath(r.URL.Path) {52 t.roomStreamHTTP(w, r)53 return54 }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 return60 }61 defer done()62 r = r.WithContext(ctx)63 }64 if t.tryDataHTTP(w, r) {65 return66 }67 if r.URL.Path == "/session" || r.URL.Path == "/session/logout" {68 t.sessionHTTP(w, r)69 return70 }71 if strings.HasPrefix(r.URL.Path, "/push/") || r.URL.Path == "/inbox/seen" {72 t.pushHTTP(w, r)73 return74 }75 if r.URL.Path == "/chat/preferences" {76 t.chatPreferencesHTTP(w, r)77 return78 }79 if r.URL.Path == "/mcp" {80 t.mcpHTTP(w, r)81 return82 }83 if r.URL.Path == "/llms.txt" {84 t.llmsHTTP(w, r)85 return86 }87 if t.tryBrowseHTTP(w, r) {88 return89 }90 if t.blobs != nil && t.roomAttachmentHTTP(w, r) {91 return92 }93 if isViewArtifactPath(r.URL.Path) {94 t.customViews.viewArtifactHTTP(w, r)95 return96 }97 if t.sites != nil && t.sites.MatchesHost(r.Host) {98 t.sites.Handler().ServeHTTP(w, r)99 return100 }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 return106 }107 if p.Reads != "open" && !privatePolicy(p) {108 http.Error(w, "restricted: public Git hosting requires open reads", http.StatusForbidden)109 return110 }111 if privatePolicy(p) && strings.TrimSpace(r.Header.Get("Authorization")) == "" {112 writeGRASP08Challenge(w)113 return114 }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 return120 }121 if accessErr := t.requirePrivateAccess(r.Context(), proof.PubKey); accessErr != nil {122 writeGRASP08Challenge(w)123 return124 }125 r = privateGitRequest(r, proof)126 }127 t.git.ServeHTTP(w, r)128 return129 }130 if t.blobs != nil && blob.HandlesPath(r.URL.Path) {131 if !t.Policy().Features.Files {132 http.NotFound(w, r)133 return134 }135 t.blobs.Handler().ServeHTTP(w, r)136 return137 }138 if r.Method == http.MethodPost && strings.Contains(r.Header.Get("Content-Type"), "application/nostr+json+rpc") {139 t.manageHTTP(w, r)140 return141 }142 if r.Method == http.MethodPost && (r.URL.Path == "/events" || r.URL.Path == "/query" || r.URL.Path == "/count") {143 t.bridge(w, r)144 return145 }146 if r.URL.Path == "/.well-known/nostr.json" {147 if _, exists := r.URL.Query()["path"]; exists {148 t.webAddress(w, r)149 return150 }151 t.nip05(w, r)152 return153 }154 if strings.Contains(r.Header.Get("Accept"), "application/nostr+json") {155 t.information(w, r)156 return157 }158 if t.ui != nil {159 t.ui.ServeHTTP(w, r)160 return161 }162 if r.URL.Path == "/" {163 t.home(w, r)164 return165 }166 http.NotFound(w, r)167}168169func writeGRASP08Challenge(w http.ResponseWriter) {170 w.Header().Set("WWW-Authenticate", `Nostr method="GET"`)171 w.WriteHeader(http.StatusUnauthorized)172}173174func (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 return179 }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 == nil195 },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, nil200 }201 raw, err := json.Marshal(filter)202 if err != nil {203 return nil, false, err204 }205 f, err := event.ParseFilter(raw)206 if err != nil {207 return nil, false, err208 }209 rows, err := t.Query(ctx, []event.Filter{f}, session)210 return filter, len(rows) > 0, err211 },212 }).ServeHTTP(w, r)213}214215func 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 return220 }221 w.Header().Set("Content-Type", "application/json; charset=utf-8")222 w.WriteHeader(status)223 if _, err := w.Write(raw); err != nil {224 return225 }226}227228func (t *Tenant) requestBody(w http.ResponseWriter, r *http.Request) ([]byte, error) {229 var reader io.Reader = r.Body230 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, err236 }237 return body, nil238}239240func (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.RemoteAddr244 }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, err250 }251 s.PubKeys = []string{e.PubKey}252 return s, nil253}254255// cookieReadSession lets a signed-in browser read through the query bridge256// 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.259func (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, err267 }268 if actor == "" {269 return s, errors.New("auth-required: sign this request")270 }271 s.PubKeys = []string{actor}272 return s, nil273}274275func (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 return280 }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 return288 }289 if r.URL.Path == "/events" {290 t.httpPublish(w, r, s, body)291 return292 }293 var raw []json.RawMessage294 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 return297 }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 return304 }305 filters[i] = f306 }307 var result any308 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 return316 }317 writeJSON(w, 200, result)318}319320func (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 return325 }326 message, err := t.router.Publish(r.Context(), e, s)327 status := 200328 if err != nil {329 message = err.Error()330 status = 400331 }332 writeJSON(w, status, map[string]any{"event_id": e.ID, "accepted": err == nil, "message": message})333}334335func statusFor(err error) int {336 if strings.HasPrefix(err.Error(), "auth-required:") {337 return 401338 }339 if strings.HasPrefix(err.Error(), "restricted:") || strings.HasPrefix(err.Error(), "blocked:") {340 return 403341 }342 return 400343}344345func (t *Tenant) information(w http.ResponseWriter, r *http.Request) {346 p := t.Policy()347 nips := []int{}348 var agents *Capability349 for _, capability := range t.Capabilities(nil) {350 if capability.Status != "enabled" {351 continue352 }353 if capability.ID == "agents" {354 entry := capability355 agents = &entry356 }357 if number, ok := capabilityNIPNumber(capability.ID); ok {358 nips = append(nips, number)359 }360 }361 name := p.Name362 if name == "" {363 name = t.meta.Name364 }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.MaxMessageBytes368 }369 if p.MaxFuture > 0 {370 limits["created_at_upper_limit"] = p.MaxFuture371 }372 if p.MinPow > 0 {373 limits["min_pow_difficulty"] = p.MinPow374 }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 service378 // identity and protocol markers; tenant presentation metadata stays379 // behind authenticated reads.380 info = t.privateNIP11()381 info["supported_nips"] = nips382 info["software"] = "https://github.com/FelineStateMachine/tinyrelay"383 info["version"] = t.app.cfg.Version384 info["self_url"] = t.RelayURL()385 info["limitation"] = limits386 }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"] = *agents395 }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 break407 }408 if value != "" {409 info[key] = value410 }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.Tags417 }418 if len(p.LanguageTags) > 0 && !privatePolicy(p) {419 info["language_tags"] = p.LanguageTags420 }421 if len(p.RelayCountries) > 0 && !privatePolicy(p) {422 info["relay_countries"] = p.RelayCountries423 }424 raw, err := json.Marshal(info)425 if err != nil {426 http.Error(w, err.Error(), 500)427 return428 }429 w.Header().Set("Content-Type", "application/nostr+json")430 if _, err := w.Write(raw); err != nil {431 return432 }433}434435func (t *Tenant) nip05(w http.ResponseWriter, r *http.Request) {436 if !t.Policy().Features.Names {437 http.NotFound(w, r)438 return439 }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 return445 }446 if err := t.requirePrivateAccess(r.Context(), actor); err != nil {447 http.Error(w, err.Error(), http.StatusForbidden)448 return449 }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 return455 }456 writeJSON(w, 200, result)457}458459func (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 return464 }465 p := t.Policy()466 name := p.Name467 if name == "" {468 name = t.meta.Name469 }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}475476func (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 return481 }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 return489 }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 return496 }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 return503 }504 writeJSON(w, 200, map[string]any{"result": result})505}506507// 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 current510// membership and resource permissions. Results retain each method's wire shape.511func (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, admissionErr515 }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] = true532 result = append(result, method)533 }534 }535 return result, nil536 }537 role, err := t.community.Role(ctx, actor)538 if err != nil {539 return nil, err540 }541 if result, handled, err := t.executeManagement(ctx, actor, method, params); handled {542 return result, err543 }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 string555 if err := json.Unmarshal(params[0], &id); err != nil {556 return nil, err557 }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(), nil569 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.RawMessage577 if err := json.Unmarshal(params[0], &patch); err != nil {578 return nil, err579 }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}600601func (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, err609}610611func 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 true615 default:616 return false617 }618}619620func containsString(values []string, wanted string) bool {621 for _, value := range values {622 if value == wanted {623 return true624 }625 }626 return false627}628629// unclaimedSiteHost reports whether host is a subdomain of the site domain630// that neither a hosted site nor a custom host claims.631func (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 false636 }637 if t.sites != nil && t.sites.MatchesHost(host) {638 return false639 }640 for _, custom := range t.Policy().CustomHosts {641 if strings.EqualFold(custom.Host, host) {642 return false643 }644 }645 return true646}