| 1 | package sites |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "crypto/tls" |
| 8 | "database/sql" |
| 9 | "encoding/hex" |
| 10 | "encoding/json" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "io" |
| 14 | "net" |
| 15 | "net/http" |
| 16 | "net/url" |
| 17 | "strconv" |
| 18 | "strings" |
| 19 | "time" |
| 20 | |
| 21 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 22 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 23 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 24 | ) |
| 25 | |
| 26 | // Blob is a fetched site file and its declared content metadata. |
| 27 | type Blob struct { |
| 28 | Body io.ReadCloser |
| 29 | Type string |
| 30 | Size int64 |
| 31 | } |
| 32 | |
| 33 | // BlobGet opens a local blob by its SHA-256 hash. The caller closes Blob.Body. |
| 34 | type BlobGet func(ctx context.Context, blobSHA string) (Blob, error) |
| 35 | |
| 36 | // BlobPut stores bytes under blobSHA with the supplied content type. The |
| 37 | // callback consumes the reader before returning. |
| 38 | type BlobPut func(ctx context.Context, blobSHA string, contentType string, body io.Reader) error |
| 39 | |
| 40 | // Fetch performs an outbound site file request. The caller closes the |
| 41 | // returned response body. |
| 42 | type Fetch func(ctx context.Context, request *http.Request) (*http.Response, error) |
| 43 | |
| 44 | // ResolveIP resolves a host before an outbound site request is allowed. |
| 45 | type ResolveIP func(ctx context.Context, host string) ([]net.IP, error) |
| 46 | |
| 47 | // ResolveHost resolves a request host to a site label. |
| 48 | type ResolveHost func(ctx context.Context, host string) (label string, err error) |
| 49 | |
| 50 | // ReadAccess authorizes a request for site content. |
| 51 | type ReadAccess func(request *http.Request) bool |
| 52 | |
| 53 | // Config supplies storage, blob, network and request policy dependencies. |
| 54 | type Config struct { |
| 55 | Store *storage.Store |
| 56 | GetBlob BlobGet |
| 57 | PutBlob BlobPut |
| 58 | Fetch Fetch |
| 59 | ResolveIP ResolveIP |
| 60 | MaxFetchBytes int64 |
| 61 | Policy func() policy.Policy |
| 62 | ReadAccess ReadAccess |
| 63 | BaseDomain string |
| 64 | ResolveHost ResolveHost |
| 65 | RelayURL func(*http.Request) string |
| 66 | } |
| 67 | |
| 68 | type Service struct{ config Config } |
| 69 | |
| 70 | // Mirror copies every missing manifest file from its advertised servers into |
| 71 | // the local blob store. It is safe to retry after a partial failure. |
| 72 | func (s *Service) Mirror(ctx context.Context, manifest event.Event) error { |
| 73 | if err := ValidateManifest(manifest); err != nil { |
| 74 | return err |
| 75 | } |
| 76 | if !s.config.Policy().Features.Sites.Mirror { |
| 77 | return nil |
| 78 | } |
| 79 | if s.config.PutBlob == nil { |
| 80 | return errors.New("site mirror requires a blob writer" ) |
| 81 | } |
| 82 | for _, mapping := range SitePaths(manifest) { |
| 83 | if s.config.GetBlob != nil { |
| 84 | if local, err := s.config.GetBlob(ctx, mapping[2]); err == nil { |
| 85 | if local.Body != nil { |
| 86 | _ = local.Body.Close() |
| 87 | } |
| 88 | continue |
| 89 | } |
| 90 | } |
| 91 | blob, err := s.blob(ctx, manifest, mapping) |
| 92 | if err != nil { |
| 93 | return fmt.Errorf("mirror %s: %w" , mapping[1], err) |
| 94 | } |
| 95 | data, err := readVerified(blob.Body, mapping[2], s.config.MaxFetchBytes) |
| 96 | _ = blob.Body.Close() |
| 97 | if err != nil { |
| 98 | return fmt.Errorf("verify %s: %w" , mapping[1], err) |
| 99 | } |
| 100 | if err := s.config.PutBlob(ctx, mapping[2], chooseType(blob.Type, mapping[1]), bytes.NewReader(data)); err != nil { |
| 101 | return fmt.Errorf("store %s: %w" , mapping[1], err) |
| 102 | } |
| 103 | } |
| 104 | return nil |
| 105 | } |
| 106 | |
| 107 | // RunMirrorIntent resolves the event captured by storage.AddIntents and runs |
| 108 | // the idempotent mirror operation. Unknown intent kinds are rejected so a |
| 109 | // worker cannot accidentally execute a different work queue entry here. |
| 110 | func (s *Service) RunMirrorIntent(ctx context.Context, intent storage.Intent) error { |
| 111 | if intent.Kind != "site-mirror" { |
| 112 | return fmt.Errorf("unsupported site intent %q" , intent.Kind) |
| 113 | } |
| 114 | result, err := s.config.Store.Query(ctx, event.Filter{IDs: []string{intent.EventID}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1}) |
| 115 | if err != nil { |
| 116 | return err |
| 117 | } |
| 118 | if len(result.Events) == 0 { |
| 119 | return nil |
| 120 | } |
| 121 | return s.Mirror(ctx, result.Events[0]) |
| 122 | } |
| 123 | |
| 124 | func New(config Config) (*Service, error) { |
| 125 | if config.Store == nil { |
| 126 | return nil, errors.New("sites store is required" ) |
| 127 | } |
| 128 | if config.Policy == nil { |
| 129 | config.Policy = func() policy.Policy { return policy.Defaults("" ) } |
| 130 | } |
| 131 | if config.BaseDomain == "" { |
| 132 | config.BaseDomain = "localhost" |
| 133 | } |
| 134 | if config.ResolveIP == nil { |
| 135 | config.ResolveIP = func(ctx context.Context, host string) ([]net.IP, error) { |
| 136 | return net.DefaultResolver.LookupIP(ctx, "ip" , host) |
| 137 | } |
| 138 | } |
| 139 | if config.MaxFetchBytes < 0 { |
| 140 | return nil, errors.New("sites maximum fetch bytes cannot be negative" ) |
| 141 | } |
| 142 | if err := ensureManifestSchema(context.Background(), config.Store.DB()); err != nil { |
| 143 | return nil, fmt.Errorf("sites schema: %w" , err) |
| 144 | } |
| 145 | return &Service{config: config}, nil |
| 146 | } |
| 147 | |
| 148 | func (s *Service) SaveOptions(e event.Event, now int64) storage.SaveOptions { |
| 149 | if e.Kind != KindSite && e.Kind != KindNamedSite && e.Kind != KindSiteSnapshot { |
| 150 | return storage.SaveOptions{Now: now} |
| 151 | } |
| 152 | if err := ValidateManifest(e); err != nil { |
| 153 | return storage.SaveOptions{Now: now} |
| 154 | } |
| 155 | opts := storage.SaveOptions{Now: now, BeforeCommit: func(ctx context.Context, tx *sql.Tx) error { return s.applyManifest(ctx, tx, e) }} |
| 156 | if s.config.Policy().Features.Sites.Mirror { |
| 157 | opts.Intents = []storage.Intent{{Kind: "site-mirror" , EventID: e.ID, Payload: SiteLabel(e)}} |
| 158 | } |
| 159 | return opts |
| 160 | } |
| 161 | |
| 162 | func (s *Service) ApplyTx(ctx context.Context, tx *sql.Tx, e event.Event) error { |
| 163 | return s.applyManifest(ctx, tx, e) |
| 164 | } |
| 165 | |
| 166 | // DeleteTx removes a manifest index entry in the same transaction that |
| 167 | // removes its event. Callers should invoke this for deletion and replacement |
| 168 | // paths so routing never points at an event that is no longer visible. |
| 169 | func (s *Service) DeleteTx(ctx context.Context, tx *sql.Tx, eventID string) error { |
| 170 | _, err := tx.ExecContext(ctx, "DELETE FROM site_manifests WHERE event_id=?" , eventID) |
| 171 | return err |
| 172 | } |
| 173 | |
| 174 | func (s *Service) applyManifest(ctx context.Context, tx *sql.Tx, e event.Event) error { |
| 175 | if e.Kind != KindSite && e.Kind != KindNamedSite && e.Kind != KindSiteSnapshot { |
| 176 | return nil |
| 177 | } |
| 178 | label := SiteLabel(e) |
| 179 | if label == "" { |
| 180 | return errors.New("invalid: site label" ) |
| 181 | } |
| 182 | _, err := tx.ExecContext(ctx, `INSERT INTO site_manifests(label,event_id,kind,pubkey,d,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(label) DO UPDATE SET event_id=excluded.event_id,kind=excluded.kind,pubkey=excluded.pubkey,d=excluded.d,updated_at=excluded.updated_at` , label, e.ID, e.Kind, e.PubKey, event.Tag(e, "d" ), time.Now().Unix()) |
| 183 | return err |
| 184 | } |
| 185 | |
| 186 | func (s *Service) Manifest(ctx context.Context, site Site) (event.Event, error) { |
| 187 | f := event.Filter{Kinds: []int{site.Kind}, Tags: map[string][]string{}} |
| 188 | if site.Kind == KindSiteSnapshot { |
| 189 | f.IDs = []string{site.ID} |
| 190 | } else { |
| 191 | f.Authors = []string{site.PubKey} |
| 192 | if site.Kind == KindNamedSite { |
| 193 | f.Tags["d" ] = []string{site.D} |
| 194 | } |
| 195 | } |
| 196 | result, err := s.config.Store.Query(ctx, f, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1}) |
| 197 | if err != nil { |
| 198 | return event.Event{}, err |
| 199 | } |
| 200 | if len(result.Events) == 0 { |
| 201 | return event.Event{}, sql.ErrNoRows |
| 202 | } |
| 203 | if err := ValidateManifest(result.Events[0]); err != nil { |
| 204 | return event.Event{}, err |
| 205 | } |
| 206 | return result.Events[0], nil |
| 207 | } |
| 208 | |
| 209 | func (s *Service) Handler() http.Handler { return http.HandlerFunc(s.serve) } |
| 210 | |
| 211 | // MatchesHost reports whether the daemon should dispatch this Host header to |
| 212 | // the site door before trying relay routes. |
| 213 | func (s *Service) MatchesHost(host string) bool { |
| 214 | _, ok := s.labelForHost(context.Background(), host) |
| 215 | return ok |
| 216 | } |
| 217 | |
| 218 | // HandlerForLabel returns a handler bound to a catalog-resolved site label. |
| 219 | // It is useful when the daemon has already selected a tenant from its host |
| 220 | // registry and must prevent a request from crossing into another tenant. |
| 221 | func (s *Service) HandlerForLabel(label string) http.Handler { |
| 222 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.serveLabel(w, r, label) }) |
| 223 | } |
| 224 | |
| 225 | func (s *Service) serve(w http.ResponseWriter, r *http.Request) { |
| 226 | label, ok := s.labelForHost(r.Context(), r.Host) |
| 227 | if !ok { |
| 228 | http.NotFound(w, r) |
| 229 | return |
| 230 | } |
| 231 | s.serveLabel(w, r, label) |
| 232 | } |
| 233 | |
| 234 | func (s *Service) serveLabel(w http.ResponseWriter, r *http.Request, label string) { |
| 235 | if !s.config.Policy().Features.Sites.Enabled { |
| 236 | http.NotFound(w, r) |
| 237 | return |
| 238 | } |
| 239 | if s.config.ReadAccess != nil && !s.config.ReadAccess(r) { |
| 240 | http.Error(w, "authentication required" , http.StatusUnauthorized) |
| 241 | return |
| 242 | } |
| 243 | site, ok := ParseSite(label) |
| 244 | if !ok { |
| 245 | http.NotFound(w, r) |
| 246 | return |
| 247 | } |
| 248 | manifest, err := s.Manifest(r.Context(), site) |
| 249 | if err != nil { |
| 250 | http.NotFound(w, r) |
| 251 | return |
| 252 | } |
| 253 | if r.URL.Path == "/.well-known/nostr.json" { |
| 254 | if _, present := r.URL.Query()["path" ]; present { |
| 255 | s.discovery(w, r, manifest) |
| 256 | return |
| 257 | } |
| 258 | } |
| 259 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 260 | http.Error(w, "method not allowed" , http.StatusMethodNotAllowed) |
| 261 | return |
| 262 | } |
| 263 | path, redirect, ok := sitePath(r.URL.EscapedPath(), SitePaths(manifest)) |
| 264 | if !ok { |
| 265 | http.NotFound(w, r) |
| 266 | return |
| 267 | } |
| 268 | if redirect { |
| 269 | location := *r.URL |
| 270 | location.Path = path |
| 271 | location.RawPath = "" |
| 272 | location.Host = r.Host |
| 273 | location.Scheme = "http" |
| 274 | if r.TLS != nil { |
| 275 | location.Scheme = "https" |
| 276 | } |
| 277 | http.Redirect(w, r, location.String(), http.StatusPermanentRedirect) |
| 278 | return |
| 279 | } |
| 280 | if err := s.servePath(w, r, manifest, path); err != nil { |
| 281 | http.NotFound(w, r) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | func (s *Service) discovery(w http.ResponseWriter, r *http.Request, manifest event.Event) { |
| 286 | w.Header().Set("Access-Control-Allow-Origin" , "*" ) |
| 287 | w.Header().Set("Cache-Control" , "no-store" ) |
| 288 | w.Header().Set("Access-Control-Allow-Headers" , "authorization, content-type, accept" ) |
| 289 | w.Header().Set("Access-Control-Allow-Methods" , "GET, HEAD, OPTIONS" ) |
| 290 | if r.Method == http.MethodOptions { |
| 291 | w.WriteHeader(http.StatusNoContent) |
| 292 | return |
| 293 | } |
| 294 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 295 | http.Error(w, "method not allowed" , http.StatusMethodNotAllowed) |
| 296 | return |
| 297 | } |
| 298 | values, ok := r.URL.Query()["path" ] |
| 299 | if !ok || len(values) != 1 { |
| 300 | writeDiscovery(w, http.StatusBadRequest, nil, r.Method == http.MethodHead) |
| 301 | return |
| 302 | } |
| 303 | path, ok := RequestedPath(values[0]) |
| 304 | if !ok || !validDiscoveryPath(path) { |
| 305 | writeDiscovery(w, http.StatusBadRequest, nil, r.Method == http.MethodHead) |
| 306 | return |
| 307 | } |
| 308 | selected, redirect, valid := sitePath(path, SitePaths(manifest)) |
| 309 | if redirect { |
| 310 | selected, _, valid = sitePath(path+"/" , SitePaths(manifest)) |
| 311 | } |
| 312 | if !valid || findPath(SitePaths(manifest), selected) == nil { |
| 313 | writeDiscovery(w, http.StatusOK, nil, r.Method == http.MethodHead) |
| 314 | return |
| 315 | } |
| 316 | filter := manifestFilter(manifest) |
| 317 | relay := "" |
| 318 | if s.config.RelayURL != nil { |
| 319 | relay = s.config.RelayURL(r) |
| 320 | } |
| 321 | if relay == "" { |
| 322 | relay = requestOrigin(r) |
| 323 | } |
| 324 | writeDiscovery(w, http.StatusOK, map[string]any{path: map[string]any{"filter" : filter, "relays" : []string{relay}}}, r.Method == http.MethodHead) |
| 325 | } |
| 326 | |
| 327 | func manifestFilter(e event.Event) map[string]any { |
| 328 | if e.Kind == KindSiteSnapshot { |
| 329 | return map[string]any{"ids" : []string{e.ID}, "limit" : 1} |
| 330 | } |
| 331 | result := map[string]any{"authors" : []string{e.PubKey}, "kinds" : []int{e.Kind}, "limit" : 1} |
| 332 | if e.Kind == KindNamedSite { |
| 333 | result["#d" ] = []string{event.Tag(e, "d" )} |
| 334 | } |
| 335 | return result |
| 336 | } |
| 337 | |
| 338 | func validDiscoveryPath(path string) bool { |
| 339 | if path == "" || !strings.HasPrefix(path, "/" ) || strings.ContainsAny(path, "?#\\\x00\r\n" ) { |
| 340 | return false |
| 341 | } |
| 342 | for _, part := range strings.Split(path, "/" ) { |
| 343 | if part == ".." || part == "." { |
| 344 | return false |
| 345 | } |
| 346 | } |
| 347 | return true |
| 348 | } |
| 349 | |
| 350 | func writeDiscovery(w http.ResponseWriter, status int, value any, head bool) { |
| 351 | w.Header().Set("Content-Type" , "application/json" ) |
| 352 | w.WriteHeader(status) |
| 353 | if value != nil && !head { |
| 354 | raw, _ := json.Marshal(value) |
| 355 | _, _ = w.Write(raw) |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | func requestOrigin(r *http.Request) string { |
| 360 | scheme := "http" |
| 361 | if r.TLS != nil { |
| 362 | scheme = "https" |
| 363 | } |
| 364 | return scheme + "://" + r.Host |
| 365 | } |
| 366 | |
| 367 | func (s *Service) labelForHost(ctx context.Context, host string) (string, bool) { |
| 368 | host = strings.ToLower(strings.Split(host, ":" )[0]) |
| 369 | if s.config.ResolveHost != nil { |
| 370 | label, err := s.config.ResolveHost(ctx, host) |
| 371 | if err == nil && label != "" { |
| 372 | if _, ok := ParseSite(label); ok { |
| 373 | return label, true |
| 374 | } |
| 375 | } |
| 376 | } |
| 377 | suffix := "." + strings.ToLower(strings.TrimSuffix(s.config.BaseDomain, "." )) |
| 378 | if strings.HasSuffix(host, suffix) { |
| 379 | label := strings.TrimSuffix(host, suffix) |
| 380 | if _, ok := ParseSite(label); ok { |
| 381 | return label, true |
| 382 | } |
| 383 | } |
| 384 | for _, custom := range s.config.Policy().CustomHosts { |
| 385 | if strings.EqualFold(custom.Host, host) && custom.Site != "" { |
| 386 | return custom.Site, true |
| 387 | } |
| 388 | } |
| 389 | return "" , false |
| 390 | } |
| 391 | |
| 392 | func sitePath(raw string, paths [][]string) (string, bool, bool) { |
| 393 | decoded, err := url.PathUnescape(raw) |
| 394 | if err != nil { |
| 395 | return "" , false, false |
| 396 | } |
| 397 | directory := !strings.Contains(pathBase(decoded), "." ) |
| 398 | redirect := directory && !strings.HasSuffix(decoded, "/" ) |
| 399 | if redirect { |
| 400 | decoded += "/" |
| 401 | raw += "/" |
| 402 | } |
| 403 | lookup := decoded |
| 404 | if directory { |
| 405 | lookup += "index.html" |
| 406 | } |
| 407 | for _, tag := range paths { |
| 408 | if len(tag) == 3 && (tag[1] == raw || tag[1] == lookup) { |
| 409 | if redirect { |
| 410 | return decoded, true, true |
| 411 | } |
| 412 | return tag[1], redirect, true |
| 413 | } |
| 414 | } |
| 415 | return lookup, redirect, true |
| 416 | } |
| 417 | |
| 418 | func pathBase(path string) string { |
| 419 | if i := strings.LastIndexByte(path, '/' ); i >= 0 { |
| 420 | return path[i+1:] |
| 421 | } |
| 422 | return path |
| 423 | } |
| 424 | |
| 425 | func (s *Service) servePath(w http.ResponseWriter, r *http.Request, manifest event.Event, path string) error { |
| 426 | mapping := findPath(SitePaths(manifest), path) |
| 427 | status := http.StatusOK |
| 428 | if mapping == nil { |
| 429 | mapping = findPath(SitePaths(manifest), "/404.html" ) |
| 430 | status = http.StatusNotFound |
| 431 | } |
| 432 | if mapping == nil { |
| 433 | return errors.New("site file not found" ) |
| 434 | } |
| 435 | blob, err := s.blob(r.Context(), manifest, mapping) |
| 436 | if err != nil { |
| 437 | return err |
| 438 | } |
| 439 | defer blob.Body.Close() |
| 440 | data, err := readVerified(blob.Body, mapping[2], s.config.MaxFetchBytes) |
| 441 | if err != nil { |
| 442 | return err |
| 443 | } |
| 444 | w.Header().Set("ETag" , `"` +mapping[2]+`"` ) |
| 445 | w.Header().Set("Content-Type" , chooseType(blob.Type, path)) |
| 446 | w.Header().Set("Content-Length" , strconv.Itoa(len(data))) |
| 447 | cache := "public, no-cache, must-revalidate, no-transform" |
| 448 | if s.config.Policy().Reads != "open" { |
| 449 | cache = "private, no-store, no-transform" |
| 450 | } |
| 451 | w.Header().Set("Cache-Control" , cache) |
| 452 | w.Header().Set("X-Content-Type-Options" , "nosniff" ) |
| 453 | if r.Method == http.MethodHead { |
| 454 | w.WriteHeader(status) |
| 455 | return nil |
| 456 | } |
| 457 | if r.Header.Get("If-None-Match" ) == `"` +mapping[2]+`"` && status == http.StatusOK { |
| 458 | w.WriteHeader(http.StatusNotModified) |
| 459 | return nil |
| 460 | } |
| 461 | if status == http.StatusOK { |
| 462 | http.ServeContent(w, r, path, time.Time{}, bytes.NewReader(data)) |
| 463 | return nil |
| 464 | } |
| 465 | w.WriteHeader(status) |
| 466 | _, err = w.Write(data) |
| 467 | return err |
| 468 | } |
| 469 | |
| 470 | func findPath(paths [][]string, wanted string) []string { |
| 471 | for _, path := range paths { |
| 472 | if len(path) == 3 && path[1] == wanted { |
| 473 | return path |
| 474 | } |
| 475 | } |
| 476 | return nil |
| 477 | } |
| 478 | func chooseType(blobType, path string) string { |
| 479 | if blobType != "" { |
| 480 | return blobType |
| 481 | } |
| 482 | return SiteType(path) |
| 483 | } |
| 484 | func readVerified(body io.Reader, want string, maxBytes int64) ([]byte, error) { |
| 485 | limited := body |
| 486 | if maxBytes > 0 { |
| 487 | limited = io.LimitReader(body, maxBytes+1) |
| 488 | } |
| 489 | data, err := io.ReadAll(limited) |
| 490 | if err != nil { |
| 491 | return nil, err |
| 492 | } |
| 493 | if maxBytes > 0 && int64(len(data)) > maxBytes { |
| 494 | return nil, errors.New("site file exceeds fetch limit" ) |
| 495 | } |
| 496 | sum := sha256.Sum256(data) |
| 497 | if hex.EncodeToString(sum[:]) != want { |
| 498 | return nil, errors.New("site file hash mismatch" ) |
| 499 | } |
| 500 | return data, nil |
| 501 | } |
| 502 | |
| 503 | func (s *Service) blob(ctx context.Context, manifest event.Event, mapping []string) (Blob, error) { |
| 504 | if s.config.GetBlob != nil { |
| 505 | if blob, err := s.config.GetBlob(ctx, mapping[2]); err == nil { |
| 506 | return blob, nil |
| 507 | } |
| 508 | } |
| 509 | if s.config.Fetch == nil { |
| 510 | return Blob{}, errors.New("site file is not local" ) |
| 511 | } |
| 512 | for _, raw := range s.servers(ctx, manifest) { |
| 513 | blob, err := s.fetchRemote(ctx, raw, mapping) |
| 514 | if err == nil { |
| 515 | return blob, nil |
| 516 | } |
| 517 | } |
| 518 | return Blob{}, errors.New("site file unavailable" ) |
| 519 | } |
| 520 | |
| 521 | func (s *Service) fetchRemote(ctx context.Context, raw string, mapping []string) (Blob, error) { |
| 522 | u, err := url.Parse(raw) |
| 523 | if err != nil || !safeOrigin(u) || !s.publicOrigin(ctx, u) { |
| 524 | return Blob{}, errors.New("unsafe site origin" ) |
| 525 | } |
| 526 | u.Path = strings.TrimSuffix(u.Path, "/" ) + "/" + mapping[2] |
| 527 | u.RawQuery = "" |
| 528 | var response *http.Response |
| 529 | for redirects := 0; redirects <= 3; redirects++ { |
| 530 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) |
| 531 | if err != nil { |
| 532 | return Blob{}, err |
| 533 | } |
| 534 | response, err = s.fetch(ctx, req) |
| 535 | if err != nil { |
| 536 | return Blob{}, err |
| 537 | } |
| 538 | if response == nil || response.Body == nil { |
| 539 | return Blob{}, errors.New("empty origin response" ) |
| 540 | } |
| 541 | if response.StatusCode < 300 || response.StatusCode >= 400 { |
| 542 | break |
| 543 | } |
| 544 | location := response.Header.Get("location" ) |
| 545 | response.Body.Close() |
| 546 | if location == "" { |
| 547 | return Blob{}, errors.New("origin redirect has no location" ) |
| 548 | } |
| 549 | next, err := url.Parse(location) |
| 550 | if err != nil || !safeOrigin(next) || !s.publicOrigin(ctx, next) { |
| 551 | return Blob{}, errors.New("unsafe site redirect" ) |
| 552 | } |
| 553 | next.Path = strings.TrimSuffix(next.Path, "/" ) + "/" + mapping[2] |
| 554 | next.RawQuery = "" |
| 555 | u = next |
| 556 | if redirects == 3 { |
| 557 | return Blob{}, errors.New("too many origin redirects" ) |
| 558 | } |
| 559 | } |
| 560 | if response.StatusCode < 200 || response.StatusCode >= 300 { |
| 561 | response.Body.Close() |
| 562 | return Blob{}, errors.New("origin response was not successful" ) |
| 563 | } |
| 564 | if s.config.MaxFetchBytes > 0 && response.ContentLength > s.config.MaxFetchBytes { |
| 565 | response.Body.Close() |
| 566 | return Blob{}, errors.New("site file exceeds operator fetch limit" ) |
| 567 | } |
| 568 | return Blob{Body: response.Body, Type: response.Header.Get("content-type" ), Size: response.ContentLength}, nil |
| 569 | } |
| 570 | |
| 571 | func (s *Service) fetch(ctx context.Context, req *http.Request) (*http.Response, error) { |
| 572 | if s.config.Fetch != nil { |
| 573 | return s.config.Fetch(ctx, req) |
| 574 | } |
| 575 | return s.pinnedClient(ctx, req.URL.Hostname()).Do(req) |
| 576 | } |
| 577 | |
| 578 | func (s *Service) pinnedClient(ctx context.Context, hostname string) *http.Client { |
| 579 | return &http.Client{Transport: &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{ServerName: hostname, MinVersion: tls.VersionTLS12}, DialContext: func(dialCtx context.Context, network, address string) (net.Conn, error) { |
| 580 | ips, err := s.config.ResolveIP(dialCtx, hostname) |
| 581 | if err != nil || len(ips) == 0 { |
| 582 | return nil, errors.New("origin DNS lookup failed" ) |
| 583 | } |
| 584 | for _, ip := range ips { |
| 585 | if !isPublicIP(ip) { |
| 586 | return nil, errors.New("origin resolved to a private address" ) |
| 587 | } |
| 588 | } |
| 589 | port := "443" |
| 590 | if _, p, err := net.SplitHostPort(address); err == nil { |
| 591 | port = p |
| 592 | } |
| 593 | return (&net.Dialer{}).DialContext(dialCtx, network, net.JoinHostPort(ips[0].String(), port)) |
| 594 | }}, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} |
| 595 | } |
| 596 | |
| 597 | func safeOrigin(u *url.URL) bool { |
| 598 | if u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" || net.ParseIP(u.Hostname()) != nil { |
| 599 | return false |
| 600 | } |
| 601 | return !strings.HasSuffix(u.Hostname(), ".local" ) && !strings.HasSuffix(u.Hostname(), ".internal" ) |
| 602 | } |
| 603 | |
| 604 | func (s *Service) publicOrigin(ctx context.Context, u *url.URL) bool { |
| 605 | ips, err := s.config.ResolveIP(ctx, u.Hostname()) |
| 606 | if err != nil || len(ips) == 0 { |
| 607 | return false |
| 608 | } |
| 609 | for _, ip := range ips { |
| 610 | if !isPublicIP(ip) { |
| 611 | return false |
| 612 | } |
| 613 | } |
| 614 | return true |
| 615 | } |
| 616 | |
| 617 | func isPublicIP(ip net.IP) bool { |
| 618 | return !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified() |
| 619 | } |
| 620 | |
| 621 | func (s *Service) servers(ctx context.Context, manifest event.Event) []string { |
| 622 | servers := append([]string(nil), event.TagValues(manifest, "server" )...) |
| 623 | result, err := s.config.Store.Query(ctx, event.Filter{Authors: []string{manifest.PubKey}, Kinds: []int{10063}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1}) |
| 624 | if err == nil && len(result.Events) > 0 { |
| 625 | servers = append(servers, event.TagValues(result.Events[0], "server" )...) |
| 626 | } |
| 627 | seen := make(map[string]bool, len(servers)) |
| 628 | out := make([]string, 0, len(servers)) |
| 629 | for _, value := range servers { |
| 630 | if !seen[value] { |
| 631 | seen[value] = true |
| 632 | out = append(out, value) |
| 633 | } |
| 634 | } |
| 635 | return out |
| 636 | } |