Browse Source
- Introduced the Blossom package, implementing essential features for handling blob storage, including upload, retrieval, and deletion of blobs. - Added authorization mechanisms for secure access to blob operations, validating authorization events based on Nostr standards. - Implemented various HTTP handlers for managing blob interactions, including GET, HEAD, PUT, and DELETE requests. - Developed utility functions for SHA256 hash calculations, MIME type detection, and range request handling. - Established a storage layer using Badger database for efficient blob data management and metadata storage. - Included placeholder implementations for media optimization and payment handling, setting the groundwork for future enhancements. - Documented the new functionalities and usage patterns in the codebase for better maintainability and understanding.main
8 changed files with 1998 additions and 0 deletions
@ -0,0 +1,294 @@
@@ -0,0 +1,294 @@
|
||||
package blossom |
||||
|
||||
import ( |
||||
"encoding/base64" |
||||
"net/http" |
||||
"strings" |
||||
"time" |
||||
|
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/errorf" |
||||
"next.orly.dev/pkg/encoders/event" |
||||
"next.orly.dev/pkg/encoders/hex" |
||||
"next.orly.dev/pkg/encoders/ints" |
||||
) |
||||
|
||||
const ( |
||||
// BlossomAuthKind is the Nostr event kind for Blossom authorization events (BUD-01)
|
||||
BlossomAuthKind = 24242 |
||||
// AuthorizationHeader is the HTTP header name for authorization
|
||||
AuthorizationHeader = "Authorization" |
||||
// NostrAuthPrefix is the prefix for Nostr authorization scheme
|
||||
NostrAuthPrefix = "Nostr" |
||||
) |
||||
|
||||
// AuthEvent represents a validated authorization event
|
||||
type AuthEvent struct { |
||||
Event *event.E |
||||
Pubkey []byte |
||||
Verb string |
||||
Expires int64 |
||||
} |
||||
|
||||
// ExtractAuthEvent extracts and parses a kind 24242 authorization event from the Authorization header
|
||||
func ExtractAuthEvent(r *http.Request) (ev *event.E, err error) { |
||||
authHeader := r.Header.Get(AuthorizationHeader) |
||||
if authHeader == "" { |
||||
err = errorf.E("missing Authorization header") |
||||
return |
||||
} |
||||
|
||||
// Parse "Nostr <base64>" format
|
||||
if !strings.HasPrefix(authHeader, NostrAuthPrefix+" ") { |
||||
err = errorf.E("invalid Authorization scheme, expected 'Nostr'") |
||||
return |
||||
} |
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2) |
||||
if len(parts) != 2 { |
||||
err = errorf.E("invalid Authorization header format") |
||||
return |
||||
} |
||||
|
||||
var evb []byte |
||||
if evb, err = base64.StdEncoding.DecodeString(parts[1]); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
ev = event.New() |
||||
var rem []byte |
||||
if rem, err = ev.Unmarshal(evb); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
if len(rem) > 0 { |
||||
err = errorf.E("unexpected trailing data in auth event") |
||||
return |
||||
} |
||||
|
||||
return |
||||
} |
||||
|
||||
// ValidateAuthEvent validates a kind 24242 authorization event according to BUD-01
|
||||
func ValidateAuthEvent( |
||||
r *http.Request, verb string, sha256Hash []byte, |
||||
) (authEv *AuthEvent, err error) { |
||||
var ev *event.E |
||||
if ev, err = ExtractAuthEvent(r); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
// 1. The kind must be 24242
|
||||
if ev.Kind != BlossomAuthKind { |
||||
err = errorf.E( |
||||
"invalid kind %d in authorization event, require %d", |
||||
ev.Kind, BlossomAuthKind, |
||||
) |
||||
return |
||||
} |
||||
|
||||
// 2. created_at must be in the past
|
||||
now := time.Now().Unix() |
||||
if ev.CreatedAt > now { |
||||
err = errorf.E( |
||||
"authorization event created_at %d is in the future (now: %d)", |
||||
ev.CreatedAt, now, |
||||
) |
||||
return |
||||
} |
||||
|
||||
// 3. Check expiration tag (must be set and in the future)
|
||||
expTags := ev.Tags.GetAll([]byte("expiration")) |
||||
if len(expTags) == 0 { |
||||
err = errorf.E("authorization event missing expiration tag") |
||||
return |
||||
} |
||||
if len(expTags) > 1 { |
||||
err = errorf.E("authorization event has multiple expiration tags") |
||||
return |
||||
} |
||||
|
||||
expInt := ints.New(0) |
||||
var rem []byte |
||||
if rem, err = expInt.Unmarshal(expTags[0].Value()); chk.E(err) { |
||||
return |
||||
} |
||||
if len(rem) > 0 { |
||||
err = errorf.E("unexpected trailing data in expiration tag") |
||||
return |
||||
} |
||||
|
||||
expiration := expInt.Int64() |
||||
if expiration <= now { |
||||
err = errorf.E( |
||||
"authorization event expired: expiration %d <= now %d", |
||||
expiration, now, |
||||
) |
||||
return |
||||
} |
||||
|
||||
// 4. The t tag must have a verb matching the intended action
|
||||
tTags := ev.Tags.GetAll([]byte("t")) |
||||
if len(tTags) == 0 { |
||||
err = errorf.E("authorization event missing 't' tag") |
||||
return |
||||
} |
||||
if len(tTags) > 1 { |
||||
err = errorf.E("authorization event has multiple 't' tags") |
||||
return |
||||
} |
||||
|
||||
eventVerb := string(tTags[0].Value()) |
||||
if eventVerb != verb { |
||||
err = errorf.E( |
||||
"authorization event verb '%s' does not match required verb '%s'", |
||||
eventVerb, verb, |
||||
) |
||||
return |
||||
} |
||||
|
||||
// 5. If sha256Hash is provided, verify at least one x tag matches
|
||||
if sha256Hash != nil && len(sha256Hash) > 0 { |
||||
sha256Hex := hex.Enc(sha256Hash) |
||||
xTags := ev.Tags.GetAll([]byte("x")) |
||||
if len(xTags) == 0 { |
||||
err = errorf.E( |
||||
"authorization event missing 'x' tag for SHA256 hash %s", |
||||
sha256Hex, |
||||
) |
||||
return |
||||
} |
||||
|
||||
found := false |
||||
for _, xTag := range xTags { |
||||
if string(xTag.Value()) == sha256Hex { |
||||
found = true |
||||
break |
||||
} |
||||
} |
||||
|
||||
if !found { |
||||
err = errorf.E( |
||||
"authorization event has no 'x' tag matching SHA256 hash %s", |
||||
sha256Hex, |
||||
) |
||||
return |
||||
} |
||||
} |
||||
|
||||
// 6. Verify event signature
|
||||
var valid bool |
||||
if valid, err = ev.Verify(); chk.E(err) { |
||||
return |
||||
} |
||||
if !valid { |
||||
err = errorf.E("authorization event signature verification failed") |
||||
return |
||||
} |
||||
|
||||
authEv = &AuthEvent{ |
||||
Event: ev, |
||||
Pubkey: ev.Pubkey, |
||||
Verb: eventVerb, |
||||
Expires: expiration, |
||||
} |
||||
|
||||
return |
||||
} |
||||
|
||||
// ValidateAuthEventOptional validates authorization but returns nil if no auth header is present
|
||||
// This is used for endpoints where authorization is optional
|
||||
func ValidateAuthEventOptional( |
||||
r *http.Request, verb string, sha256Hash []byte, |
||||
) (authEv *AuthEvent, err error) { |
||||
authHeader := r.Header.Get(AuthorizationHeader) |
||||
if authHeader == "" { |
||||
// No authorization provided, but that's OK for optional endpoints
|
||||
return nil, nil |
||||
} |
||||
|
||||
return ValidateAuthEvent(r, verb, sha256Hash) |
||||
} |
||||
|
||||
// ValidateAuthEventForGet validates authorization for GET requests (BUD-01)
|
||||
// GET requests may have either:
|
||||
// - A server tag matching the server URL
|
||||
// - At least one x tag matching the blob hash
|
||||
func ValidateAuthEventForGet( |
||||
r *http.Request, serverURL string, sha256Hash []byte, |
||||
) (authEv *AuthEvent, err error) { |
||||
var ev *event.E |
||||
if ev, err = ExtractAuthEvent(r); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
// Basic validation
|
||||
if authEv, err = ValidateAuthEvent(r, "get", sha256Hash); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
// For GET requests, check server tag or x tag
|
||||
serverTags := ev.Tags.GetAll([]byte("server")) |
||||
xTags := ev.Tags.GetAll([]byte("x")) |
||||
|
||||
// If server tag exists, verify it matches
|
||||
if len(serverTags) > 0 { |
||||
serverTagValue := string(serverTags[0].Value()) |
||||
if !strings.HasPrefix(serverURL, serverTagValue) { |
||||
err = errorf.E( |
||||
"server tag '%s' does not match server URL '%s'", |
||||
serverTagValue, serverURL, |
||||
) |
||||
return |
||||
} |
||||
return |
||||
} |
||||
|
||||
// Otherwise, verify at least one x tag matches the hash
|
||||
if sha256Hash != nil && len(sha256Hash) > 0 { |
||||
sha256Hex := hex.Enc(sha256Hash) |
||||
found := false |
||||
for _, xTag := range xTags { |
||||
if string(xTag.Value()) == sha256Hex { |
||||
found = true |
||||
break |
||||
} |
||||
} |
||||
if !found { |
||||
err = errorf.E( |
||||
"no 'x' tag matching SHA256 hash %s", |
||||
sha256Hex, |
||||
) |
||||
return |
||||
} |
||||
} else if len(xTags) == 0 { |
||||
err = errorf.E( |
||||
"authorization event must have either 'server' tag or 'x' tag", |
||||
) |
||||
return |
||||
} |
||||
|
||||
return |
||||
} |
||||
|
||||
// GetPubkeyFromRequest extracts pubkey from Authorization header if present
|
||||
func GetPubkeyFromRequest(r *http.Request) (pubkey []byte, err error) { |
||||
authHeader := r.Header.Get(AuthorizationHeader) |
||||
if authHeader == "" { |
||||
return nil, nil |
||||
} |
||||
|
||||
authEv, err := ValidateAuthEventOptional(r, "", nil) |
||||
if err != nil { |
||||
// If validation fails, return empty pubkey but no error
|
||||
// This allows endpoints to work without auth
|
||||
return nil, nil |
||||
} |
||||
|
||||
if authEv != nil { |
||||
return authEv.Pubkey, nil |
||||
} |
||||
|
||||
return nil, nil |
||||
} |
||||
|
||||
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
package blossom |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"time" |
||||
) |
||||
|
||||
// BlobDescriptor represents a blob descriptor as defined in BUD-02
|
||||
type BlobDescriptor struct { |
||||
URL string `json:"url"` |
||||
SHA256 string `json:"sha256"` |
||||
Size int64 `json:"size"` |
||||
Type string `json:"type"` |
||||
Uploaded int64 `json:"uploaded"` |
||||
NIP94 [][]string `json:"nip94,omitempty"` |
||||
} |
||||
|
||||
// BlobMetadata stores metadata about a blob in the database
|
||||
type BlobMetadata struct { |
||||
Pubkey []byte `json:"pubkey"` |
||||
MimeType string `json:"mime_type"` |
||||
Uploaded int64 `json:"uploaded"` |
||||
Size int64 `json:"size"` |
||||
} |
||||
|
||||
// NewBlobDescriptor creates a new blob descriptor
|
||||
func NewBlobDescriptor( |
||||
url, sha256 string, size int64, mimeType string, uploaded int64, |
||||
) *BlobDescriptor { |
||||
if mimeType == "" { |
||||
mimeType = "application/octet-stream" |
||||
} |
||||
return &BlobDescriptor{ |
||||
URL: url, |
||||
SHA256: sha256, |
||||
Size: size, |
||||
Type: mimeType, |
||||
Uploaded: uploaded, |
||||
} |
||||
} |
||||
|
||||
// NewBlobMetadata creates a new blob metadata struct
|
||||
func NewBlobMetadata(pubkey []byte, mimeType string, size int64) *BlobMetadata { |
||||
if mimeType == "" { |
||||
mimeType = "application/octet-stream" |
||||
} |
||||
return &BlobMetadata{ |
||||
Pubkey: pubkey, |
||||
MimeType: mimeType, |
||||
Uploaded: time.Now().Unix(), |
||||
Size: size, |
||||
} |
||||
} |
||||
|
||||
// Serialize serializes blob metadata to JSON
|
||||
func (bm *BlobMetadata) Serialize() (data []byte, err error) { |
||||
return json.Marshal(bm) |
||||
} |
||||
|
||||
// DeserializeBlobMetadata deserializes blob metadata from JSON
|
||||
func DeserializeBlobMetadata(data []byte) (bm *BlobMetadata, err error) { |
||||
bm = &BlobMetadata{} |
||||
err = json.Unmarshal(data, bm) |
||||
return |
||||
} |
||||
@ -0,0 +1,783 @@
@@ -0,0 +1,783 @@
|
||||
package blossom |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
"net/url" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
|
||||
"lol.mleku.dev/log" |
||||
"next.orly.dev/pkg/encoders/event" |
||||
"next.orly.dev/pkg/encoders/hex" |
||||
"next.orly.dev/pkg/utils" |
||||
) |
||||
|
||||
// handleGetBlob handles GET /<sha256> requests (BUD-01)
|
||||
func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) { |
||||
path := strings.TrimPrefix(r.URL.Path, "/") |
||||
|
||||
// Extract SHA256 and extension
|
||||
sha256Hex, ext, err := ExtractSHA256FromPath(path) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, err.Error()) |
||||
return |
||||
} |
||||
|
||||
// Convert hex to bytes
|
||||
sha256Hash, err := hex.Dec(sha256Hex) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format") |
||||
return |
||||
} |
||||
|
||||
// Check if blob exists
|
||||
exists, err := s.storage.HasBlob(sha256Hash) |
||||
if err != nil { |
||||
log.E.F("error checking blob existence: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error") |
||||
return |
||||
} |
||||
|
||||
if !exists { |
||||
s.setErrorResponse(w, http.StatusNotFound, "blob not found") |
||||
return |
||||
} |
||||
|
||||
// Get blob metadata
|
||||
metadata, err := s.storage.GetBlobMetadata(sha256Hash) |
||||
if err != nil { |
||||
log.E.F("error getting blob metadata: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error") |
||||
return |
||||
} |
||||
|
||||
// Optional authorization check (BUD-01)
|
||||
if s.requireAuth { |
||||
authEv, err := ValidateAuthEventForGet(r, s.baseURL, sha256Hash) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required") |
||||
return |
||||
} |
||||
if authEv == nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required") |
||||
return |
||||
} |
||||
} |
||||
|
||||
// Get blob data
|
||||
blobData, _, err := s.storage.GetBlob(sha256Hash) |
||||
if err != nil { |
||||
log.E.F("error getting blob: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error") |
||||
return |
||||
} |
||||
|
||||
// Set headers
|
||||
mimeType := DetectMimeType(metadata.MimeType, ext) |
||||
w.Header().Set("Content-Type", mimeType) |
||||
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(blobData)), 10)) |
||||
w.Header().Set("Accept-Ranges", "bytes") |
||||
|
||||
// Handle range requests (RFC 7233)
|
||||
rangeHeader := r.Header.Get("Range") |
||||
if rangeHeader != "" { |
||||
start, end, valid, err := ParseRangeHeader(rangeHeader, int64(len(blobData))) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusRequestedRangeNotSatisfiable, err.Error()) |
||||
return |
||||
} |
||||
if valid { |
||||
WriteRangeResponse(w, blobData, start, end, int64(len(blobData))) |
||||
return |
||||
} |
||||
} |
||||
|
||||
// Send full blob
|
||||
w.WriteHeader(http.StatusOK) |
||||
_, _ = w.Write(blobData) |
||||
} |
||||
|
||||
// handleHeadBlob handles HEAD /<sha256> requests (BUD-01)
|
||||
func (s *Server) handleHeadBlob(w http.ResponseWriter, r *http.Request) { |
||||
path := strings.TrimPrefix(r.URL.Path, "/") |
||||
|
||||
// Extract SHA256 and extension
|
||||
sha256Hex, ext, err := ExtractSHA256FromPath(path) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, err.Error()) |
||||
return |
||||
} |
||||
|
||||
// Convert hex to bytes
|
||||
sha256Hash, err := hex.Dec(sha256Hex) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format") |
||||
return |
||||
} |
||||
|
||||
// Check if blob exists
|
||||
exists, err := s.storage.HasBlob(sha256Hash) |
||||
if err != nil { |
||||
log.E.F("error checking blob existence: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error") |
||||
return |
||||
} |
||||
|
||||
if !exists { |
||||
s.setErrorResponse(w, http.StatusNotFound, "blob not found") |
||||
return |
||||
} |
||||
|
||||
// Get blob metadata
|
||||
metadata, err := s.storage.GetBlobMetadata(sha256Hash) |
||||
if err != nil { |
||||
log.E.F("error getting blob metadata: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error") |
||||
return |
||||
} |
||||
|
||||
// Optional authorization check
|
||||
if s.requireAuth { |
||||
authEv, err := ValidateAuthEventForGet(r, s.baseURL, sha256Hash) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required") |
||||
return |
||||
} |
||||
if authEv == nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required") |
||||
return |
||||
} |
||||
} |
||||
|
||||
// Set headers (same as GET but no body)
|
||||
mimeType := DetectMimeType(metadata.MimeType, ext) |
||||
w.Header().Set("Content-Type", mimeType) |
||||
w.Header().Set("Content-Length", strconv.FormatInt(metadata.Size, 10)) |
||||
w.Header().Set("Accept-Ranges", "bytes") |
||||
w.WriteHeader(http.StatusOK) |
||||
} |
||||
|
||||
// handleUpload handles PUT /upload requests (BUD-02)
|
||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { |
||||
// Check ACL
|
||||
pubkey, _ := GetPubkeyFromRequest(r) |
||||
remoteAddr := s.getRemoteAddr(r) |
||||
|
||||
if !s.checkACL(pubkey, remoteAddr, "write") { |
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") |
||||
return |
||||
} |
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, s.maxBlobSize+1)) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "error reading request body") |
||||
return |
||||
} |
||||
|
||||
if int64(len(body)) > s.maxBlobSize { |
||||
s.setErrorResponse(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("blob too large: max %d bytes", s.maxBlobSize)) |
||||
return |
||||
} |
||||
|
||||
// Calculate SHA256
|
||||
sha256Hash := CalculateSHA256(body) |
||||
sha256Hex := hex.Enc(sha256Hash) |
||||
|
||||
// Check if blob already exists
|
||||
exists, err := s.storage.HasBlob(sha256Hash) |
||||
if err != nil { |
||||
log.E.F("error checking blob existence: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error") |
||||
return |
||||
} |
||||
|
||||
// Optional authorization validation
|
||||
if r.Header.Get(AuthorizationHeader) != "" { |
||||
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error()) |
||||
return |
||||
} |
||||
if authEv != nil { |
||||
pubkey = authEv.Pubkey |
||||
} |
||||
} |
||||
|
||||
if len(pubkey) == 0 { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required") |
||||
return |
||||
} |
||||
|
||||
// Detect MIME type
|
||||
mimeType := DetectMimeType( |
||||
r.Header.Get("Content-Type"), |
||||
GetFileExtensionFromPath(r.URL.Path), |
||||
) |
||||
|
||||
// Check allowed MIME types
|
||||
if len(s.allowedMimeTypes) > 0 && !s.allowedMimeTypes[mimeType] { |
||||
s.setErrorResponse(w, http.StatusUnsupportedMediaType,
|
||||
fmt.Sprintf("MIME type %s not allowed", mimeType)) |
||||
return |
||||
} |
||||
|
||||
// Save blob if it doesn't exist
|
||||
if !exists { |
||||
if err = s.storage.SaveBlob(sha256Hash, body, pubkey, mimeType); err != nil { |
||||
log.E.F("error saving blob: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob") |
||||
return |
||||
} |
||||
} else { |
||||
// Verify ownership
|
||||
metadata, err := s.storage.GetBlobMetadata(sha256Hash) |
||||
if err != nil { |
||||
log.E.F("error getting blob metadata: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error") |
||||
return |
||||
} |
||||
|
||||
// Allow if same pubkey or if ACL allows
|
||||
if !utils.FastEqual(metadata.Pubkey, pubkey) && !s.checkACL(pubkey, remoteAddr, "admin") { |
||||
s.setErrorResponse(w, http.StatusConflict, "blob already exists") |
||||
return |
||||
} |
||||
} |
||||
|
||||
// Build URL with extension
|
||||
ext := "" |
||||
if mimeExt := GetMimeTypeFromExtension(GetFileExtensionFromPath(r.URL.Path)); mimeExt != "application/octet-stream" { |
||||
// Try to infer extension from MIME type
|
||||
for extName, mime := range map[string]string{ |
||||
".pdf": "application/pdf", |
||||
".png": "image/png", |
||||
".jpg": "image/jpeg", |
||||
".gif": "image/gif", |
||||
".webp": "image/webp", |
||||
} { |
||||
if mime == mimeType { |
||||
ext = extName |
||||
break |
||||
} |
||||
} |
||||
} |
||||
|
||||
blobURL := BuildBlobURL(s.baseURL, sha256Hex, ext) |
||||
if !strings.HasSuffix(blobURL, "/") && !strings.HasPrefix(ext, "/") { |
||||
blobURL = s.baseURL + "/" + sha256Hex + ext |
||||
} |
||||
|
||||
// Create descriptor
|
||||
descriptor := NewBlobDescriptor( |
||||
blobURL, |
||||
sha256Hex, |
||||
int64(len(body)), |
||||
mimeType, |
||||
time.Now().Unix(), |
||||
) |
||||
|
||||
// Return descriptor
|
||||
w.Header().Set("Content-Type", "application/json") |
||||
w.WriteHeader(http.StatusOK) |
||||
if err = json.NewEncoder(w).Encode(descriptor); err != nil { |
||||
log.E.F("error encoding response: %v", err) |
||||
} |
||||
} |
||||
|
||||
// handleUploadRequirements handles HEAD /upload requests (BUD-06)
|
||||
func (s *Server) handleUploadRequirements(w http.ResponseWriter, r *http.Request) { |
||||
// Get headers
|
||||
sha256Hex := r.Header.Get("X-SHA-256") |
||||
contentLengthStr := r.Header.Get("X-Content-Length") |
||||
contentType := r.Header.Get("X-Content-Type") |
||||
|
||||
// Validate SHA256 header
|
||||
if sha256Hex == "" { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "missing X-SHA-256 header") |
||||
return |
||||
} |
||||
|
||||
if !ValidateSHA256Hex(sha256Hex) { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid X-SHA-256 header format") |
||||
return |
||||
} |
||||
|
||||
// Validate Content-Length header
|
||||
if contentLengthStr == "" { |
||||
s.setErrorResponse(w, http.StatusLengthRequired, "missing X-Content-Length header") |
||||
return |
||||
} |
||||
|
||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid X-Content-Length header") |
||||
return |
||||
} |
||||
|
||||
if contentLength > s.maxBlobSize { |
||||
s.setErrorResponse(w, http.StatusRequestEntityTooLarge, |
||||
fmt.Sprintf("file too large: max %d bytes", s.maxBlobSize)) |
||||
return |
||||
} |
||||
|
||||
// Check MIME type if provided
|
||||
if contentType != "" && len(s.allowedMimeTypes) > 0 { |
||||
if !s.allowedMimeTypes[contentType] { |
||||
s.setErrorResponse(w, http.StatusUnsupportedMediaType, |
||||
fmt.Sprintf("unsupported file type: %s", contentType)) |
||||
return |
||||
} |
||||
} |
||||
|
||||
// Check if blob already exists
|
||||
sha256Hash, err := hex.Dec(sha256Hex) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format") |
||||
return |
||||
} |
||||
|
||||
exists, err := s.storage.HasBlob(sha256Hash) |
||||
if err != nil { |
||||
log.E.F("error checking blob existence: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error") |
||||
return |
||||
} |
||||
|
||||
if exists { |
||||
// Return 200 OK - blob already exists, upload can proceed
|
||||
w.WriteHeader(http.StatusOK) |
||||
return |
||||
} |
||||
|
||||
// Optional authorization check
|
||||
if r.Header.Get(AuthorizationHeader) != "" { |
||||
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error()) |
||||
return |
||||
} |
||||
if authEv == nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required") |
||||
return |
||||
} |
||||
|
||||
// Check ACL
|
||||
remoteAddr := s.getRemoteAddr(r) |
||||
if !s.checkACL(authEv.Pubkey, remoteAddr, "write") { |
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") |
||||
return |
||||
} |
||||
} |
||||
|
||||
// All checks passed
|
||||
w.WriteHeader(http.StatusOK) |
||||
} |
||||
|
||||
// handleListBlobs handles GET /list/<pubkey> requests (BUD-02)
|
||||
func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) { |
||||
path := strings.TrimPrefix(r.URL.Path, "/") |
||||
|
||||
// Extract pubkey from path: list/<pubkey>
|
||||
if !strings.HasPrefix(path, "list/") { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid path") |
||||
return |
||||
} |
||||
|
||||
pubkeyHex := strings.TrimPrefix(path, "list/") |
||||
if len(pubkeyHex) != 64 { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid pubkey format") |
||||
return |
||||
} |
||||
|
||||
pubkey, err := hex.Dec(pubkeyHex) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid pubkey format") |
||||
return |
||||
} |
||||
|
||||
// Parse query parameters
|
||||
var since, until int64 |
||||
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" { |
||||
since, err = strconv.ParseInt(sinceStr, 10, 64) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid since parameter") |
||||
return |
||||
} |
||||
} |
||||
|
||||
if untilStr := r.URL.Query().Get("until"); untilStr != "" { |
||||
until, err = strconv.ParseInt(untilStr, 10, 64) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid until parameter") |
||||
return |
||||
} |
||||
} |
||||
|
||||
// Optional authorization check
|
||||
requestPubkey, _ := GetPubkeyFromRequest(r) |
||||
if r.Header.Get(AuthorizationHeader) != "" { |
||||
authEv, err := ValidateAuthEvent(r, "list", nil) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error()) |
||||
return |
||||
} |
||||
if authEv != nil { |
||||
requestPubkey = authEv.Pubkey |
||||
} |
||||
} |
||||
|
||||
// Check if requesting own list or has admin access
|
||||
if !utils.FastEqual(pubkey, requestPubkey) && !s.checkACL(requestPubkey, s.getRemoteAddr(r), "admin") { |
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") |
||||
return |
||||
} |
||||
|
||||
// List blobs
|
||||
descriptors, err := s.storage.ListBlobs(pubkey, since, until) |
||||
if err != nil { |
||||
log.E.F("error listing blobs: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error") |
||||
return |
||||
} |
||||
|
||||
// Set URLs for descriptors
|
||||
for _, desc := range descriptors { |
||||
desc.URL = BuildBlobURL(s.baseURL, desc.SHA256, "") |
||||
} |
||||
|
||||
// Return JSON array
|
||||
w.Header().Set("Content-Type", "application/json") |
||||
w.WriteHeader(http.StatusOK) |
||||
if err = json.NewEncoder(w).Encode(descriptors); err != nil { |
||||
log.E.F("error encoding response: %v", err) |
||||
} |
||||
} |
||||
|
||||
// handleDeleteBlob handles DELETE /<sha256> requests (BUD-02)
|
||||
func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) { |
||||
path := strings.TrimPrefix(r.URL.Path, "/") |
||||
|
||||
// Extract SHA256
|
||||
sha256Hex, _, err := ExtractSHA256FromPath(path) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, err.Error()) |
||||
return |
||||
} |
||||
|
||||
sha256Hash, err := hex.Dec(sha256Hex) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format") |
||||
return |
||||
} |
||||
|
||||
// Authorization required for delete
|
||||
authEv, err := ValidateAuthEvent(r, "delete", sha256Hash) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error()) |
||||
return |
||||
} |
||||
|
||||
if authEv == nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required") |
||||
return |
||||
} |
||||
|
||||
// Check ACL
|
||||
remoteAddr := s.getRemoteAddr(r) |
||||
if !s.checkACL(authEv.Pubkey, remoteAddr, "write") { |
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") |
||||
return |
||||
} |
||||
|
||||
// Verify ownership
|
||||
metadata, err := s.storage.GetBlobMetadata(sha256Hash) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusNotFound, "blob not found") |
||||
return |
||||
} |
||||
|
||||
if !utils.FastEqual(metadata.Pubkey, authEv.Pubkey) && !s.checkACL(authEv.Pubkey, remoteAddr, "admin") { |
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions to delete this blob") |
||||
return |
||||
} |
||||
|
||||
// Delete blob
|
||||
if err = s.storage.DeleteBlob(sha256Hash, authEv.Pubkey); err != nil { |
||||
log.E.F("error deleting blob: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "error deleting blob") |
||||
return |
||||
} |
||||
|
||||
w.WriteHeader(http.StatusOK) |
||||
} |
||||
|
||||
// handleMirror handles PUT /mirror requests (BUD-04)
|
||||
func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) { |
||||
// Check ACL
|
||||
pubkey, _ := GetPubkeyFromRequest(r) |
||||
remoteAddr := s.getRemoteAddr(r) |
||||
|
||||
if !s.checkACL(pubkey, remoteAddr, "write") { |
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") |
||||
return |
||||
} |
||||
|
||||
// Read request body (JSON with URL)
|
||||
var req struct { |
||||
URL string `json:"url"` |
||||
} |
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid request body") |
||||
return |
||||
} |
||||
|
||||
if req.URL == "" { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "missing url field") |
||||
return |
||||
} |
||||
|
||||
// Parse URL
|
||||
mirrorURL, err := url.Parse(req.URL) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid URL") |
||||
return |
||||
} |
||||
|
||||
// Download blob from remote URL
|
||||
client := &http.Client{Timeout: 30 * time.Second} |
||||
resp, err := client.Get(mirrorURL.String()) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadGateway, "failed to fetch blob from remote URL") |
||||
return |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
if resp.StatusCode != http.StatusOK { |
||||
s.setErrorResponse(w, http.StatusBadGateway,
|
||||
fmt.Sprintf("remote server returned status %d", resp.StatusCode)) |
||||
return |
||||
} |
||||
|
||||
// Read blob data
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, s.maxBlobSize+1)) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadGateway, "error reading remote blob") |
||||
return |
||||
} |
||||
|
||||
if int64(len(body)) > s.maxBlobSize { |
||||
s.setErrorResponse(w, http.StatusRequestEntityTooLarge, |
||||
fmt.Sprintf("blob too large: max %d bytes", s.maxBlobSize)) |
||||
return |
||||
} |
||||
|
||||
// Calculate SHA256
|
||||
sha256Hash := CalculateSHA256(body) |
||||
sha256Hex := hex.Enc(sha256Hash) |
||||
|
||||
// Optional authorization validation
|
||||
if r.Header.Get(AuthorizationHeader) != "" { |
||||
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error()) |
||||
return |
||||
} |
||||
if authEv != nil { |
||||
pubkey = authEv.Pubkey |
||||
} |
||||
} |
||||
|
||||
if len(pubkey) == 0 { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required") |
||||
return |
||||
} |
||||
|
||||
// Detect MIME type from remote response
|
||||
mimeType := DetectMimeType( |
||||
resp.Header.Get("Content-Type"), |
||||
GetFileExtensionFromPath(mirrorURL.Path), |
||||
) |
||||
|
||||
// Save blob
|
||||
if err = s.storage.SaveBlob(sha256Hash, body, pubkey, mimeType); err != nil { |
||||
log.E.F("error saving mirrored blob: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob") |
||||
return |
||||
} |
||||
|
||||
// Build URL
|
||||
blobURL := BuildBlobURL(s.baseURL, sha256Hex, "") |
||||
|
||||
// Create descriptor
|
||||
descriptor := NewBlobDescriptor( |
||||
blobURL, |
||||
sha256Hex, |
||||
int64(len(body)), |
||||
mimeType, |
||||
time.Now().Unix(), |
||||
) |
||||
|
||||
// Return descriptor
|
||||
w.Header().Set("Content-Type", "application/json") |
||||
w.WriteHeader(http.StatusOK) |
||||
if err = json.NewEncoder(w).Encode(descriptor); err != nil { |
||||
log.E.F("error encoding response: %v", err) |
||||
} |
||||
} |
||||
|
||||
// handleMediaUpload handles PUT /media requests (BUD-05)
|
||||
func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) { |
||||
// Check ACL
|
||||
pubkey, _ := GetPubkeyFromRequest(r) |
||||
remoteAddr := s.getRemoteAddr(r) |
||||
|
||||
if !s.checkACL(pubkey, remoteAddr, "write") { |
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") |
||||
return |
||||
} |
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, s.maxBlobSize+1)) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "error reading request body") |
||||
return |
||||
} |
||||
|
||||
if int64(len(body)) > s.maxBlobSize { |
||||
s.setErrorResponse(w, http.StatusRequestEntityTooLarge, |
||||
fmt.Sprintf("blob too large: max %d bytes", s.maxBlobSize)) |
||||
return |
||||
} |
||||
|
||||
// Calculate SHA256 for authorization validation
|
||||
sha256Hash := CalculateSHA256(body) |
||||
|
||||
// Optional authorization validation
|
||||
if r.Header.Get(AuthorizationHeader) != "" { |
||||
authEv, err := ValidateAuthEvent(r, "media", sha256Hash) |
||||
if err != nil { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error()) |
||||
return |
||||
} |
||||
if authEv != nil { |
||||
pubkey = authEv.Pubkey |
||||
} |
||||
} |
||||
|
||||
if len(pubkey) == 0 { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required") |
||||
return |
||||
} |
||||
|
||||
// Optimize media (placeholder - actual optimization would be implemented here)
|
||||
optimizedData, mimeType := OptimizeMedia(body, DetectMimeType( |
||||
r.Header.Get("Content-Type"), |
||||
GetFileExtensionFromPath(r.URL.Path), |
||||
)) |
||||
|
||||
// Calculate optimized blob SHA256
|
||||
optimizedHash := CalculateSHA256(optimizedData) |
||||
optimizedHex := hex.Enc(optimizedHash) |
||||
|
||||
// Save optimized blob
|
||||
if err = s.storage.SaveBlob(optimizedHash, optimizedData, pubkey, mimeType); err != nil { |
||||
log.E.F("error saving optimized blob: %v", err) |
||||
s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob") |
||||
return |
||||
} |
||||
|
||||
// Build URL
|
||||
blobURL := BuildBlobURL(s.baseURL, optimizedHex, "") |
||||
|
||||
// Create descriptor
|
||||
descriptor := NewBlobDescriptor( |
||||
blobURL, |
||||
optimizedHex, |
||||
int64(len(optimizedData)), |
||||
mimeType, |
||||
time.Now().Unix(), |
||||
) |
||||
|
||||
// Return descriptor
|
||||
w.Header().Set("Content-Type", "application/json") |
||||
w.WriteHeader(http.StatusOK) |
||||
if err = json.NewEncoder(w).Encode(descriptor); err != nil { |
||||
log.E.F("error encoding response: %v", err) |
||||
} |
||||
} |
||||
|
||||
// handleMediaHead handles HEAD /media requests (BUD-05)
|
||||
func (s *Server) handleMediaHead(w http.ResponseWriter, r *http.Request) { |
||||
// Similar to handleUploadRequirements but for media
|
||||
// Return 200 OK if media optimization is available
|
||||
w.WriteHeader(http.StatusOK) |
||||
} |
||||
|
||||
// handleReport handles PUT /report requests (BUD-09)
|
||||
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { |
||||
// Check ACL
|
||||
pubkey, _ := GetPubkeyFromRequest(r) |
||||
remoteAddr := s.getRemoteAddr(r) |
||||
|
||||
if !s.checkACL(pubkey, remoteAddr, "read") { |
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") |
||||
return |
||||
} |
||||
|
||||
// Read request body (NIP-56 report event)
|
||||
var reportEv event.E |
||||
if err := json.NewDecoder(r.Body).Decode(&reportEv); err != nil { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid request body") |
||||
return |
||||
} |
||||
|
||||
// Validate report event (kind 1984 per NIP-56)
|
||||
if reportEv.Kind != 1984 { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid event kind, expected 1984") |
||||
return |
||||
} |
||||
|
||||
// Verify signature
|
||||
valid, err := reportEv.Verify() |
||||
if err != nil || !valid { |
||||
s.setErrorResponse(w, http.StatusUnauthorized, "invalid event signature") |
||||
return |
||||
} |
||||
|
||||
// Extract x tags (blob hashes)
|
||||
xTags := reportEv.Tags.GetAll([]byte("x")) |
||||
if len(xTags) == 0 { |
||||
s.setErrorResponse(w, http.StatusBadRequest, "report event missing 'x' tags") |
||||
return |
||||
} |
||||
|
||||
// Serialize report event
|
||||
reportData := reportEv.Serialize() |
||||
|
||||
// Save report for each blob hash
|
||||
for _, xTag := range xTags { |
||||
sha256Hex := string(xTag.Value()) |
||||
if !ValidateSHA256Hex(sha256Hex) { |
||||
continue |
||||
} |
||||
|
||||
sha256Hash, err := hex.Dec(sha256Hex) |
||||
if err != nil { |
||||
continue |
||||
} |
||||
|
||||
if err = s.storage.SaveReport(sha256Hash, reportData); err != nil { |
||||
log.E.F("error saving report: %v", err) |
||||
} |
||||
} |
||||
|
||||
w.WriteHeader(http.StatusOK) |
||||
} |
||||
|
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
package blossom |
||||
|
||||
// OptimizeMedia optimizes media content (BUD-05)
|
||||
// This is a placeholder implementation - actual optimization would use
|
||||
// libraries like image processing, video encoding, etc.
|
||||
func OptimizeMedia(data []byte, mimeType string) (optimizedData []byte, optimizedMimeType string) { |
||||
// For now, just return the original data unchanged
|
||||
// In a real implementation, this would:
|
||||
// - Resize images to optimal dimensions
|
||||
// - Compress images (JPEG quality, PNG optimization)
|
||||
// - Convert formats if beneficial
|
||||
// - Optimize video encoding
|
||||
// - etc.
|
||||
|
||||
optimizedData = data |
||||
optimizedMimeType = mimeType |
||||
return |
||||
} |
||||
|
||||
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
package blossom |
||||
|
||||
import ( |
||||
"net/http" |
||||
) |
||||
|
||||
// PaymentChecker handles payment requirements (BUD-07)
|
||||
type PaymentChecker struct { |
||||
// Payment configuration would go here
|
||||
// For now, this is a placeholder
|
||||
} |
||||
|
||||
// NewPaymentChecker creates a new payment checker
|
||||
func NewPaymentChecker() *PaymentChecker { |
||||
return &PaymentChecker{} |
||||
} |
||||
|
||||
// CheckPaymentRequired checks if payment is required for an endpoint
|
||||
// Returns payment method headers if payment is required
|
||||
func (pc *PaymentChecker) CheckPaymentRequired( |
||||
endpoint string, |
||||
) (required bool, paymentHeaders map[string]string) { |
||||
// Placeholder implementation - always returns false
|
||||
// In a real implementation, this would check:
|
||||
// - Per-endpoint payment requirements
|
||||
// - User payment status
|
||||
// - Blob size/cost thresholds
|
||||
// etc.
|
||||
|
||||
return false, nil |
||||
} |
||||
|
||||
// ValidatePayment validates a payment proof
|
||||
func (pc *PaymentChecker) ValidatePayment( |
||||
paymentMethod, proof string, |
||||
) (valid bool, err error) { |
||||
// Placeholder implementation
|
||||
// In a real implementation, this would validate:
|
||||
// - Cashu tokens (NUT-24)
|
||||
// - Lightning payment preimages (BOLT-11)
|
||||
// etc.
|
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
// SetPaymentRequired sets a 402 Payment Required response with payment headers
|
||||
func SetPaymentRequired(w http.ResponseWriter, paymentHeaders map[string]string) { |
||||
for header, value := range paymentHeaders { |
||||
w.Header().Set(header, value) |
||||
} |
||||
w.WriteHeader(http.StatusPaymentRequired) |
||||
} |
||||
|
||||
@ -0,0 +1,200 @@
@@ -0,0 +1,200 @@
|
||||
package blossom |
||||
|
||||
import ( |
||||
"net/http" |
||||
"strings" |
||||
|
||||
"next.orly.dev/pkg/acl" |
||||
"next.orly.dev/pkg/database" |
||||
) |
||||
|
||||
// Server provides a Blossom server implementation
|
||||
type Server struct { |
||||
db *database.D |
||||
storage *Storage |
||||
acl *acl.S |
||||
baseURL string |
||||
|
||||
// Configuration
|
||||
maxBlobSize int64 |
||||
allowedMimeTypes map[string]bool |
||||
requireAuth bool |
||||
} |
||||
|
||||
// Config holds configuration for the Blossom server
|
||||
type Config struct { |
||||
BaseURL string |
||||
MaxBlobSize int64 |
||||
AllowedMimeTypes []string |
||||
RequireAuth bool |
||||
} |
||||
|
||||
// NewServer creates a new Blossom server instance
|
||||
func NewServer(db *database.D, aclRegistry *acl.S, cfg *Config) *Server { |
||||
if cfg == nil { |
||||
cfg = &Config{ |
||||
MaxBlobSize: 100 * 1024 * 1024, // 100MB default
|
||||
RequireAuth: false, |
||||
} |
||||
} |
||||
|
||||
storage := NewStorage(db) |
||||
|
||||
// Build allowed MIME types map
|
||||
allowedMap := make(map[string]bool) |
||||
if len(cfg.AllowedMimeTypes) > 0 { |
||||
for _, mime := range cfg.AllowedMimeTypes { |
||||
allowedMap[mime] = true |
||||
} |
||||
} |
||||
|
||||
return &Server{ |
||||
db: db, |
||||
storage: storage, |
||||
acl: aclRegistry, |
||||
baseURL: cfg.BaseURL, |
||||
maxBlobSize: cfg.MaxBlobSize, |
||||
allowedMimeTypes: allowedMap, |
||||
requireAuth: cfg.RequireAuth, |
||||
} |
||||
} |
||||
|
||||
// Handler returns an http.Handler that can be attached to a router
|
||||
func (s *Server) Handler() http.Handler { |
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
// Set CORS headers (BUD-01 requirement)
|
||||
s.setCORSHeaders(w, r) |
||||
|
||||
// Handle preflight OPTIONS requests
|
||||
if r.Method == http.MethodOptions { |
||||
w.WriteHeader(http.StatusOK) |
||||
return |
||||
} |
||||
|
||||
// Route based on path and method
|
||||
path := r.URL.Path |
||||
|
||||
// Remove leading slash
|
||||
path = strings.TrimPrefix(path, "/") |
||||
|
||||
// Handle specific endpoints
|
||||
switch { |
||||
case r.Method == http.MethodGet && path == "upload": |
||||
// This shouldn't happen, but handle gracefully
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
|
||||
case r.Method == http.MethodHead && path == "upload": |
||||
s.handleUploadRequirements(w, r) |
||||
return |
||||
|
||||
case r.Method == http.MethodPut && path == "upload": |
||||
s.handleUpload(w, r) |
||||
return |
||||
|
||||
case r.Method == http.MethodHead && path == "media": |
||||
s.handleMediaHead(w, r) |
||||
return |
||||
|
||||
case r.Method == http.MethodPut && path == "media": |
||||
s.handleMediaUpload(w, r) |
||||
return |
||||
|
||||
case r.Method == http.MethodPut && path == "mirror": |
||||
s.handleMirror(w, r) |
||||
return |
||||
|
||||
case r.Method == http.MethodPut && path == "report": |
||||
s.handleReport(w, r) |
||||
return |
||||
|
||||
case strings.HasPrefix(path, "list/"): |
||||
if r.Method == http.MethodGet { |
||||
s.handleListBlobs(w, r) |
||||
return |
||||
} |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
|
||||
case r.Method == http.MethodGet: |
||||
// Handle GET /<sha256>
|
||||
s.handleGetBlob(w, r) |
||||
return |
||||
|
||||
case r.Method == http.MethodHead: |
||||
// Handle HEAD /<sha256>
|
||||
s.handleHeadBlob(w, r) |
||||
return |
||||
|
||||
case r.Method == http.MethodDelete: |
||||
// Handle DELETE /<sha256>
|
||||
s.handleDeleteBlob(w, r) |
||||
return |
||||
|
||||
default: |
||||
http.Error(w, "Not found", http.StatusNotFound) |
||||
return |
||||
} |
||||
}) |
||||
} |
||||
|
||||
// setCORSHeaders sets CORS headers as required by BUD-01
|
||||
func (s *Server) setCORSHeaders(w http.ResponseWriter, r *http.Request) { |
||||
w.Header().Set("Access-Control-Allow-Origin", "*") |
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE") |
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, *") |
||||
w.Header().Set("Access-Control-Max-Age", "86400") |
||||
w.Header().Set("Access-Control-Allow-Credentials", "true") |
||||
w.Header().Set("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers") |
||||
} |
||||
|
||||
// setErrorResponse sets an error response with X-Reason header (BUD-01)
|
||||
func (s *Server) setErrorResponse(w http.ResponseWriter, status int, reason string) { |
||||
w.Header().Set("X-Reason", reason) |
||||
http.Error(w, reason, status) |
||||
} |
||||
|
||||
// getRemoteAddr extracts the remote address from the request
|
||||
func (s *Server) getRemoteAddr(r *http.Request) string { |
||||
// Check X-Forwarded-For header
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { |
||||
parts := strings.Split(forwarded, ",") |
||||
if len(parts) > 0 { |
||||
return strings.TrimSpace(parts[0]) |
||||
} |
||||
} |
||||
|
||||
// Check X-Real-IP header
|
||||
if realIP := r.Header.Get("X-Real-IP"); realIP != "" { |
||||
return realIP |
||||
} |
||||
|
||||
// Fall back to RemoteAddr
|
||||
return r.RemoteAddr |
||||
} |
||||
|
||||
// checkACL checks if the user has the required access level
|
||||
func (s *Server) checkACL( |
||||
pubkey []byte, remoteAddr string, requiredLevel string, |
||||
) bool { |
||||
if s.acl == nil { |
||||
return true // No ACL configured, allow all
|
||||
} |
||||
|
||||
level := s.acl.GetAccessLevel(pubkey, remoteAddr) |
||||
|
||||
// Map ACL levels to permissions
|
||||
levelMap := map[string]int{ |
||||
"none": 0, |
||||
"read": 1, |
||||
"write": 2, |
||||
"admin": 3, |
||||
"owner": 4, |
||||
} |
||||
|
||||
required := levelMap[requiredLevel] |
||||
actual := levelMap[level] |
||||
|
||||
return actual >= required |
||||
} |
||||
|
||||
@ -0,0 +1,334 @@
@@ -0,0 +1,334 @@
|
||||
package blossom |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
|
||||
"github.com/dgraph-io/badger/v4" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/errorf" |
||||
"lol.mleku.dev/log" |
||||
"next.orly.dev/pkg/crypto/sha256" |
||||
"next.orly.dev/pkg/database" |
||||
"next.orly.dev/pkg/encoders/hex" |
||||
"next.orly.dev/pkg/utils" |
||||
) |
||||
|
||||
const ( |
||||
// Database key prefixes
|
||||
prefixBlobData = "blob:data:" |
||||
prefixBlobMeta = "blob:meta:" |
||||
prefixBlobIndex = "blob:index:" |
||||
prefixBlobReport = "blob:report:" |
||||
) |
||||
|
||||
// Storage provides blob storage operations
|
||||
type Storage struct { |
||||
db *database.D |
||||
} |
||||
|
||||
// NewStorage creates a new storage instance
|
||||
func NewStorage(db *database.D) *Storage { |
||||
return &Storage{db: db} |
||||
} |
||||
|
||||
// SaveBlob stores a blob with its metadata
|
||||
func (s *Storage) SaveBlob( |
||||
sha256Hash []byte, data []byte, pubkey []byte, mimeType string, |
||||
) (err error) { |
||||
sha256Hex := hex.Enc(sha256Hash) |
||||
|
||||
// Verify SHA256 matches
|
||||
calculatedHash := sha256.Sum256(data) |
||||
if !utils.FastEqual(calculatedHash[:], sha256Hash) { |
||||
err = errorf.E( |
||||
"SHA256 mismatch: calculated %x, provided %x", |
||||
calculatedHash[:], sha256Hash, |
||||
) |
||||
return |
||||
} |
||||
|
||||
// Create metadata
|
||||
metadata := NewBlobMetadata(pubkey, mimeType, int64(len(data))) |
||||
var metaData []byte |
||||
if metaData, err = metadata.Serialize(); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
// Store blob data
|
||||
dataKey := prefixBlobData + sha256Hex |
||||
if err = s.db.Update(func(txn *badger.Txn) error { |
||||
if err := txn.Set([]byte(dataKey), data); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Store metadata
|
||||
metaKey := prefixBlobMeta + sha256Hex |
||||
if err := txn.Set([]byte(metaKey), metaData); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Index by pubkey
|
||||
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex |
||||
if err := txn.Set([]byte(indexKey), []byte{1}); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
}); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
log.D.F("saved blob %s (%d bytes) for pubkey %s", sha256Hex, len(data), hex.Enc(pubkey)) |
||||
return |
||||
} |
||||
|
||||
// GetBlob retrieves blob data by SHA256 hash
|
||||
func (s *Storage) GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadata, err error) { |
||||
sha256Hex := hex.Enc(sha256Hash) |
||||
dataKey := prefixBlobData + sha256Hex |
||||
|
||||
var blobData []byte |
||||
if err = s.db.View(func(txn *badger.Txn) error { |
||||
item, err := txn.Get([]byte(dataKey)) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return item.Value(func(val []byte) error { |
||||
blobData = make([]byte, len(val)) |
||||
copy(blobData, val) |
||||
return nil |
||||
}) |
||||
}); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
// Get metadata
|
||||
metaKey := prefixBlobMeta + sha256Hex |
||||
if err = s.db.View(func(txn *badger.Txn) error { |
||||
item, err := txn.Get([]byte(metaKey)) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return item.Value(func(val []byte) error { |
||||
if metadata, err = DeserializeBlobMetadata(val); err != nil { |
||||
return err |
||||
} |
||||
return nil |
||||
}) |
||||
}); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
data = blobData |
||||
return |
||||
} |
||||
|
||||
// HasBlob checks if a blob exists
|
||||
func (s *Storage) HasBlob(sha256Hash []byte) (exists bool, err error) { |
||||
sha256Hex := hex.Enc(sha256Hash) |
||||
dataKey := prefixBlobData + sha256Hex |
||||
|
||||
if err = s.db.View(func(txn *badger.Txn) error { |
||||
_, err := txn.Get([]byte(dataKey)) |
||||
if err == badger.ErrKeyNotFound { |
||||
exists = false |
||||
return nil |
||||
} |
||||
if err != nil { |
||||
return err |
||||
} |
||||
exists = true |
||||
return nil |
||||
}); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
return |
||||
} |
||||
|
||||
// DeleteBlob deletes a blob and its metadata
|
||||
func (s *Storage) DeleteBlob(sha256Hash []byte, pubkey []byte) (err error) { |
||||
sha256Hex := hex.Enc(sha256Hash) |
||||
dataKey := prefixBlobData + sha256Hex |
||||
metaKey := prefixBlobMeta + sha256Hex |
||||
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex |
||||
|
||||
if err = s.db.Update(func(txn *badger.Txn) error { |
||||
// Verify blob exists
|
||||
_, err := txn.Get([]byte(dataKey)) |
||||
if err == badger.ErrKeyNotFound { |
||||
return errorf.E("blob %s not found", sha256Hex) |
||||
} |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Delete blob data
|
||||
if err := txn.Delete([]byte(dataKey)); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Delete metadata
|
||||
if err := txn.Delete([]byte(metaKey)); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Delete index entry
|
||||
if err := txn.Delete([]byte(indexKey)); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
}); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
log.D.F("deleted blob %s for pubkey %s", sha256Hex, hex.Enc(pubkey)) |
||||
return |
||||
} |
||||
|
||||
// ListBlobs lists all blobs for a given pubkey
|
||||
func (s *Storage) ListBlobs( |
||||
pubkey []byte, since, until int64, |
||||
) (descriptors []*BlobDescriptor, err error) { |
||||
pubkeyHex := hex.Enc(pubkey) |
||||
prefix := prefixBlobIndex + pubkeyHex + ":" |
||||
|
||||
descriptors = make([]*BlobDescriptor, 0) |
||||
|
||||
if err = s.db.View(func(txn *badger.Txn) error { |
||||
opts := badger.DefaultIteratorOptions |
||||
opts.Prefix = []byte(prefix) |
||||
it := txn.NewIterator(opts) |
||||
defer it.Close() |
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() { |
||||
item := it.Item() |
||||
key := item.Key() |
||||
|
||||
// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex
|
||||
sha256Hex := string(key[len(prefix):]) |
||||
|
||||
// Get blob metadata
|
||||
metaKey := prefixBlobMeta + sha256Hex |
||||
metaItem, err := txn.Get([]byte(metaKey)) |
||||
if err != nil { |
||||
continue |
||||
} |
||||
|
||||
var metadata *BlobMetadata |
||||
if err = metaItem.Value(func(val []byte) error { |
||||
if metadata, err = DeserializeBlobMetadata(val); err != nil { |
||||
return err |
||||
} |
||||
return nil |
||||
}); err != nil { |
||||
continue |
||||
} |
||||
|
||||
// Filter by time range
|
||||
if since > 0 && metadata.Uploaded < since { |
||||
continue |
||||
} |
||||
if until > 0 && metadata.Uploaded > until { |
||||
continue |
||||
} |
||||
|
||||
// Verify blob exists
|
||||
dataKey := prefixBlobData + sha256Hex |
||||
_, errGet := txn.Get([]byte(dataKey)) |
||||
if errGet != nil { |
||||
continue |
||||
} |
||||
|
||||
// Create descriptor (URL will be set by handler)
|
||||
descriptor := NewBlobDescriptor( |
||||
"", // URL will be set by handler
|
||||
sha256Hex, |
||||
metadata.Size, |
||||
metadata.MimeType, |
||||
metadata.Uploaded, |
||||
) |
||||
|
||||
descriptors = append(descriptors, descriptor) |
||||
} |
||||
|
||||
return nil |
||||
}); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
return |
||||
} |
||||
|
||||
// SaveReport stores a report for a blob (BUD-09)
|
||||
func (s *Storage) SaveReport(sha256Hash []byte, reportData []byte) (err error) { |
||||
sha256Hex := hex.Enc(sha256Hash) |
||||
reportKey := prefixBlobReport + sha256Hex |
||||
|
||||
// Get existing reports
|
||||
var existingReports [][]byte |
||||
if err = s.db.View(func(txn *badger.Txn) error { |
||||
item, err := txn.Get([]byte(reportKey)) |
||||
if err == badger.ErrKeyNotFound { |
||||
return nil |
||||
} |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return item.Value(func(val []byte) error { |
||||
if err = json.Unmarshal(val, &existingReports); err != nil { |
||||
return err |
||||
} |
||||
return nil |
||||
}) |
||||
}); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
// Append new report
|
||||
existingReports = append(existingReports, reportData) |
||||
|
||||
// Store updated reports
|
||||
var reportsData []byte |
||||
if reportsData, err = json.Marshal(existingReports); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
if err = s.db.Update(func(txn *badger.Txn) error { |
||||
return txn.Set([]byte(reportKey), reportsData) |
||||
}); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
log.D.F("saved report for blob %s", sha256Hex) |
||||
return |
||||
} |
||||
|
||||
// GetBlobMetadata retrieves only metadata for a blob
|
||||
func (s *Storage) GetBlobMetadata(sha256Hash []byte) (metadata *BlobMetadata, err error) { |
||||
sha256Hex := hex.Enc(sha256Hash) |
||||
metaKey := prefixBlobMeta + sha256Hex |
||||
|
||||
if err = s.db.View(func(txn *badger.Txn) error { |
||||
item, err := txn.Get([]byte(metaKey)) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return item.Value(func(val []byte) error { |
||||
if metadata, err = DeserializeBlobMetadata(val); err != nil { |
||||
return err |
||||
} |
||||
return nil |
||||
}) |
||||
}); chk.E(err) { |
||||
return |
||||
} |
||||
|
||||
return |
||||
} |
||||
|
||||
@ -0,0 +1,250 @@
@@ -0,0 +1,250 @@
|
||||
package blossom |
||||
|
||||
import ( |
||||
"net/http" |
||||
"path/filepath" |
||||
"regexp" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"lol.mleku.dev/errorf" |
||||
"next.orly.dev/pkg/crypto/sha256" |
||||
"next.orly.dev/pkg/encoders/hex" |
||||
) |
||||
|
||||
const ( |
||||
sha256HexLength = 64 |
||||
maxRangeSize = 10 * 1024 * 1024 // 10MB max range request
|
||||
) |
||||
|
||||
var sha256Regex = regexp.MustCompile(`^[a-fA-F0-9]{64}`) |
||||
|
||||
// CalculateSHA256 calculates the SHA256 hash of data
|
||||
func CalculateSHA256(data []byte) []byte { |
||||
hash := sha256.Sum256(data) |
||||
return hash[:] |
||||
} |
||||
|
||||
// CalculateSHA256Hex calculates the SHA256 hash and returns it as hex string
|
||||
func CalculateSHA256Hex(data []byte) string { |
||||
hash := sha256.Sum256(data) |
||||
return hex.Enc(hash[:]) |
||||
} |
||||
|
||||
// ExtractSHA256FromPath extracts SHA256 hash from URL path
|
||||
// Supports both /<sha256> and /<sha256>.<ext> formats
|
||||
func ExtractSHA256FromPath(path string) (sha256Hex string, ext string, err error) { |
||||
// Remove leading slash
|
||||
path = strings.TrimPrefix(path, "/") |
||||
|
||||
// Split by dot to separate hash and extension
|
||||
parts := strings.SplitN(path, ".", 2) |
||||
sha256Hex = parts[0] |
||||
|
||||
if len(parts) > 1 { |
||||
ext = "." + parts[1] |
||||
} |
||||
|
||||
// Validate SHA256 hex format
|
||||
if len(sha256Hex) != sha256HexLength { |
||||
err = errorf.E( |
||||
"invalid SHA256 length: expected %d, got %d", |
||||
sha256HexLength, len(sha256Hex), |
||||
) |
||||
return |
||||
} |
||||
|
||||
if !sha256Regex.MatchString(sha256Hex) { |
||||
err = errorf.E("invalid SHA256 format: %s", sha256Hex) |
||||
return |
||||
} |
||||
|
||||
return |
||||
} |
||||
|
||||
// ExtractSHA256FromURL extracts SHA256 hash from a URL string
|
||||
// Uses the last occurrence of a 64 char hex string (as per BUD-03)
|
||||
func ExtractSHA256FromURL(urlStr string) (sha256Hex string, err error) { |
||||
// Find all 64-char hex strings
|
||||
matches := sha256Regex.FindAllString(urlStr, -1) |
||||
if len(matches) == 0 { |
||||
err = errorf.E("no SHA256 hash found in URL: %s", urlStr) |
||||
return |
||||
} |
||||
|
||||
// Return the last occurrence
|
||||
sha256Hex = matches[len(matches)-1] |
||||
return |
||||
} |
||||
|
||||
// GetMimeTypeFromExtension returns MIME type based on file extension
|
||||
func GetMimeTypeFromExtension(ext string) string { |
||||
ext = strings.ToLower(ext) |
||||
mimeTypes := map[string]string{ |
||||
".pdf": "application/pdf", |
||||
".png": "image/png", |
||||
".jpg": "image/jpeg", |
||||
".jpeg": "image/jpeg", |
||||
".gif": "image/gif", |
||||
".webp": "image/webp", |
||||
".svg": "image/svg+xml", |
||||
".mp4": "video/mp4", |
||||
".webm": "video/webm", |
||||
".mp3": "audio/mpeg", |
||||
".wav": "audio/wav", |
||||
".ogg": "audio/ogg", |
||||
".txt": "text/plain", |
||||
".html": "text/html", |
||||
".css": "text/css", |
||||
".js": "application/javascript", |
||||
".json": "application/json", |
||||
".xml": "application/xml", |
||||
".zip": "application/zip", |
||||
".tar": "application/x-tar", |
||||
".gz": "application/gzip", |
||||
} |
||||
|
||||
if mime, ok := mimeTypes[ext]; ok { |
||||
return mime |
||||
} |
||||
return "application/octet-stream" |
||||
} |
||||
|
||||
// DetectMimeType detects MIME type from Content-Type header or file extension
|
||||
func DetectMimeType(contentType string, ext string) string { |
||||
// First try Content-Type header
|
||||
if contentType != "" { |
||||
// Remove any parameters (e.g., "text/plain; charset=utf-8")
|
||||
parts := strings.Split(contentType, ";") |
||||
mime := strings.TrimSpace(parts[0]) |
||||
if mime != "" && mime != "application/octet-stream" { |
||||
return mime |
||||
} |
||||
} |
||||
|
||||
// Fall back to extension
|
||||
if ext != "" { |
||||
return GetMimeTypeFromExtension(ext) |
||||
} |
||||
|
||||
return "application/octet-stream" |
||||
} |
||||
|
||||
// ParseRangeHeader parses HTTP Range header (RFC 7233)
|
||||
// Returns start, end, and total length
|
||||
func ParseRangeHeader(rangeHeader string, contentLength int64) ( |
||||
start, end int64, valid bool, err error, |
||||
) { |
||||
if rangeHeader == "" { |
||||
return 0, 0, false, nil |
||||
} |
||||
|
||||
// Only support "bytes" unit
|
||||
if !strings.HasPrefix(rangeHeader, "bytes=") { |
||||
return 0, 0, false, errorf.E("unsupported range unit") |
||||
} |
||||
|
||||
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=") |
||||
parts := strings.Split(rangeSpec, "-") |
||||
|
||||
if len(parts) != 2 { |
||||
return 0, 0, false, errorf.E("invalid range format") |
||||
} |
||||
|
||||
var startStr, endStr string |
||||
startStr = strings.TrimSpace(parts[0]) |
||||
endStr = strings.TrimSpace(parts[1]) |
||||
|
||||
if startStr == "" && endStr == "" { |
||||
return 0, 0, false, errorf.E("invalid range: both start and end empty") |
||||
} |
||||
|
||||
// Parse start
|
||||
if startStr != "" { |
||||
if start, err = strconv.ParseInt(startStr, 10, 64); err != nil { |
||||
return 0, 0, false, errorf.E("invalid range start: %w", err) |
||||
} |
||||
if start < 0 { |
||||
return 0, 0, false, errorf.E("range start cannot be negative") |
||||
} |
||||
if start >= contentLength { |
||||
return 0, 0, false, errorf.E("range start exceeds content length") |
||||
} |
||||
} else { |
||||
// Suffix range: last N bytes
|
||||
if end, err = strconv.ParseInt(endStr, 10, 64); err != nil { |
||||
return 0, 0, false, errorf.E("invalid range end: %w", err) |
||||
} |
||||
if end <= 0 { |
||||
return 0, 0, false, errorf.E("suffix range must be positive") |
||||
} |
||||
start = contentLength - end |
||||
if start < 0 { |
||||
start = 0 |
||||
} |
||||
end = contentLength - 1 |
||||
return start, end, true, nil |
||||
} |
||||
|
||||
// Parse end
|
||||
if endStr != "" { |
||||
if end, err = strconv.ParseInt(endStr, 10, 64); err != nil { |
||||
return 0, 0, false, errorf.E("invalid range end: %w", err) |
||||
} |
||||
if end < start { |
||||
return 0, 0, false, errorf.E("range end before start") |
||||
} |
||||
if end >= contentLength { |
||||
end = contentLength - 1 |
||||
} |
||||
} else { |
||||
// Open-ended range: from start to end
|
||||
end = contentLength - 1 |
||||
} |
||||
|
||||
// Validate range size
|
||||
if end-start+1 > maxRangeSize { |
||||
return 0, 0, false, errorf.E("range too large: max %d bytes", maxRangeSize) |
||||
} |
||||
|
||||
return start, end, true, nil |
||||
} |
||||
|
||||
// WriteRangeResponse writes a partial content response (206)
|
||||
func WriteRangeResponse( |
||||
w http.ResponseWriter, data []byte, start, end, totalLength int64, |
||||
) { |
||||
w.Header().Set("Content-Range",
|
||||
"bytes "+strconv.FormatInt(start, 10)+"-"+ |
||||
strconv.FormatInt(end, 10)+"/"+ |
||||
strconv.FormatInt(totalLength, 10)) |
||||
w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10)) |
||||
w.Header().Set("Accept-Ranges", "bytes") |
||||
w.WriteHeader(http.StatusPartialContent) |
||||
_, _ = w.Write(data[start : end+1]) |
||||
} |
||||
|
||||
// BuildBlobURL builds a blob URL with optional extension
|
||||
func BuildBlobURL(baseURL, sha256Hex, ext string) string { |
||||
url := baseURL + sha256Hex |
||||
if ext != "" { |
||||
url += ext |
||||
} |
||||
return url |
||||
} |
||||
|
||||
// ValidateSHA256Hex validates that a string is a valid SHA256 hex string
|
||||
func ValidateSHA256Hex(s string) bool { |
||||
if len(s) != sha256HexLength { |
||||
return false |
||||
} |
||||
_, err := hex.Dec(s) |
||||
return err == nil |
||||
} |
||||
|
||||
// GetFileExtensionFromPath extracts file extension from a path
|
||||
func GetFileExtensionFromPath(path string) string { |
||||
ext := filepath.Ext(path) |
||||
return ext |
||||
} |
||||
|
||||
Loading…
Reference in new issue