package attach import ( "context" "io" "mime" "net/http" "path" "strings" "gocloud.dev/blob" "gorm.io/gorm" ) const defaultStaticContentType = "application/octet-stream" // StaticHandler serves GET prefix// from bucket. // filename is the original disk_name or a thumb_* sibling stored in the // original's partition. The blob key is the validated 4-segment path; // unvalidated request segments never reach NewReader (T-05-13). // // This is the public-disk handler: it does not consult system_files.is_public. // Protected files must not be stored in this bucket (Winter uses a second // disk). To 404 is_public=false rows, mount StaticHandlerPublic instead. // Do not mount the ungated handler on the app origin — same-origin // Content-Type from uploads is XSS-relevant. // // Every response carries X-Content-Type-Options: nosniff. A served file gets // its stored Content-Type and, when the blob has one, its stored // Content-Disposition: Store stores attachment for every type // IsInlineContentType rejects, and a stored value mime.ParseMediaType rejects // is sent as attachment. The handler derives no disposition from the type and // sends no Content-Security-Policy, so a blob stored without a disposition is // served inline with its type. Reading the disposition costs one Attributes // call per request. func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler { return servePublicBlobs(bucket, prefix, nil) } // StaticHandlerPublic is StaticHandler plus an is_public gate. Missing // rows and is_public=false both 404. The lookup runs before NewReader. // It sends the same headers as StaticHandler. func StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler { return servePublicBlobs(bucket, prefix, func(ctx context.Context, filename string) (bool, error) { return fileIsPublic(ctx, db, filename) }) } func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Context, string) (bool, error)) http.Handler { prefix = strings.TrimSuffix(prefix, "/") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("X-Content-Type-Options", "nosniff") if bucket == nil { http.NotFound(w, r) return } rel, ok := stripStaticPrefix(r.URL.Path, prefix) if !ok { http.NotFound(w, r) return } key, ok := parsePublicBlobPath(rel) if !ok { http.NotFound(w, r) return } if allow != nil { ok, err := allow(r.Context(), path.Base(key)) if err != nil || !ok { http.NotFound(w, r) return } } attrs, err := bucket.Attributes(r.Context(), key) if err != nil { http.NotFound(w, r) return } reader, err := bucket.NewReader(r.Context(), key, nil) if err != nil { http.NotFound(w, r) return } defer reader.Close() ct := reader.ContentType() if ct == "" { ct = defaultStaticContentType } w.Header().Set("Content-Type", ct) if cd := storedDisposition(attrs.ContentDisposition); cd != "" { w.Header().Set("Content-Disposition", cd) } if r.Method == http.MethodHead { return } _, _ = io.Copy(w, reader) }) } // storedDisposition is the value of the stored Content-Disposition a handler // sends: empty for none, the trimmed value when mime.ParseMediaType accepts // it, and attachment for a value it rejects, so malformed or injected // metadata never reaches a response header. func storedDisposition(raw string) string { v := strings.TrimSpace(raw) if v == "" { return "" } if _, _, err := mime.ParseMediaType(v); err != nil { return attachmentDisposition } return v } func stripStaticPrefix(path, prefix string) (string, bool) { if prefix == "" { return strings.TrimPrefix(path, "/"), true } if path == prefix { return "", false } if strings.HasPrefix(path, prefix+"/") { return path[len(prefix)+1:], true } return "", false } // parsePublicBlobPath accepts exactly 3 partition groups plus a filename // and returns that path as the blob key. Originals must live in // PartitionDirectory(filename); thumb_* names are stored beside the // original, so their partition is not derived from the thumb filename. // Rejects "..", empty segments, extra slashes, and mismatched original // partitions. func parsePublicBlobPath(p string) (key string, ok bool) { if p == "" || strings.Contains(p, "\\") || strings.Contains(p, "..") || strings.Contains(p, "//") { return "", false } parts := strings.Split(p, "/") if len(parts) != 4 { return "", false } for _, part := range parts { if part == "" || part == "." || part == ".." { return "", false } } filename := parts[3] got := strings.Join(parts[:3], "/") if !strings.HasPrefix(filename, "thumb_") { want := strings.TrimSuffix(PartitionDirectory(filename), "/") if got != want { return "", false } } return got + "/" + filename, true }