Add Blossom blob storage server and subscription management

- Introduced the `initializeBlossomServer` function to set up the Blossom blob storage server with dynamic base URL handling and ACL configuration.
- Implemented the `blossomHandler` method to manage incoming requests to the Blossom API, ensuring proper URL handling and context management.
- Enhanced the `PaymentProcessor` to support Blossom service levels, allowing for subscription extensions based on payment metadata.
- Added methods for parsing and validating Blossom service levels, including storage quota management and subscription extension logic.
- Updated the configuration to include Blossom service level settings, facilitating dynamic service level management.
- Integrated storage quota checks in the blob upload process to prevent exceeding allocated limits.
- Refactored existing code to improve organization and maintainability, including the removal of unused blob directory configurations.
- Added tests to ensure the robustness of new functionalities and maintain existing behavior across blob operations.
This commit is contained in:
2025-11-02 22:23:01 +00:00
parent 3567bb26a4
commit edcdec9c7e
10 changed files with 421 additions and 24 deletions

53
app/blossom.go Normal file
View File

@@ -0,0 +1,53 @@
package app
import (
"context"
"net/http"
"strings"
"lol.mleku.dev/log"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
blossom "next.orly.dev/pkg/blossom"
)
// initializeBlossomServer creates and configures the Blossom blob storage server
func initializeBlossomServer(
ctx context.Context, cfg *config.C, db *database.D,
) (*blossom.Server, error) {
// Create blossom server configuration
blossomCfg := &blossom.Config{
BaseURL: "", // Will be set dynamically per request
MaxBlobSize: 100 * 1024 * 1024, // 100MB default
AllowedMimeTypes: nil, // Allow all MIME types by default
RequireAuth: cfg.AuthRequired || cfg.AuthToWrite,
}
// Create blossom server with relay's ACL registry
bs := blossom.NewServer(db, acl.Registry, blossomCfg)
// Override baseURL getter to use request-based URL
// We'll need to modify the handler to inject the baseURL per request
// For now, we'll use a middleware approach
log.I.F("blossom server initialized with ACL mode: %s", cfg.ACLMode)
return bs, nil
}
// blossomHandler wraps the blossom server handler to inject baseURL per request
func (s *Server) blossomHandler(w http.ResponseWriter, r *http.Request) {
// Strip /blossom prefix and pass to blossom handler
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/blossom")
if !strings.HasPrefix(r.URL.Path, "/") {
r.URL.Path = "/" + r.URL.Path
}
// Set baseURL in request context for blossom server to use
baseURL := s.ServiceURL(r) + "/blossom"
type baseURLKey struct{}
r = r.WithContext(context.WithValue(r.Context(), baseURLKey{}, baseURL))
s.blossomServer.Handler().ServeHTTP(w, r)
}

View File

@@ -52,6 +52,9 @@ type C struct {
RelayAddresses []string `env:"ORLY_RELAY_ADDRESSES" usage:"comma-separated list of websocket addresses for this relay (e.g., wss://relay.example.com,wss://backup.example.com)"`
FollowListFrequency time.Duration `env:"ORLY_FOLLOW_LIST_FREQUENCY" usage:"how often to fetch admin follow lists (default: 1h)" default:"1h"`
// Blossom blob storage service level settings
BlossomServiceLevels string `env:"ORLY_BLOSSOM_SERVICE_LEVELS" usage:"comma-separated list of service levels in format: name:storage_mb_per_sat_per_month (e.g., basic:1,premium:10)"`
// Web UI and dev mode settings
WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"`
WebDevProxyURL string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"`

View File

@@ -119,6 +119,14 @@ func Run(
// Initialize the user interface
l.UserInterface()
// Initialize Blossom blob storage server
if l.blossomServer, err = initializeBlossomServer(ctx, cfg, db); err != nil {
log.E.F("failed to initialize blossom server: %v", err)
// Continue without blossom server
} else if l.blossomServer != nil {
log.I.F("blossom blob storage server initialized")
}
// Ensure a relay identity secret key exists when subscriptions and NWC are enabled
if cfg.SubscriptionEnabled && cfg.NWCUri != "" {
if skb, e := db.GetOrCreateRelayIdentitySecret(); e != nil {

View File

@@ -505,7 +505,9 @@ func (pp *PaymentProcessor) handleNotification(
// Prefer explicit payer/relay pubkeys if provided in metadata
var payerPubkey []byte
var userNpub string
if metadata, ok := notification["metadata"].(map[string]any); ok {
var metadata map[string]any
if md, ok := notification["metadata"].(map[string]any); ok {
metadata = md
if s, ok := metadata["payer_pubkey"].(string); ok && s != "" {
if pk, err := decodeAnyPubkey(s); err == nil {
payerPubkey = pk
@@ -565,6 +567,11 @@ func (pp *PaymentProcessor) handleNotification(
}
satsReceived := int64(amount / 1000)
// Parse zap memo for blossom service level
blossomLevel := pp.parseBlossomServiceLevel(description, metadata)
// Calculate subscription days (for relay access)
monthlyPrice := pp.config.MonthlyPriceSats
if monthlyPrice <= 0 {
monthlyPrice = 6000
@@ -575,10 +582,19 @@ func (pp *PaymentProcessor) handleNotification(
return fmt.Errorf("payment amount too small")
}
// Extend relay subscription
if err := pp.db.ExtendSubscription(pubkey, days); err != nil {
return fmt.Errorf("failed to extend subscription: %w", err)
}
// If blossom service level specified, extend blossom subscription
if blossomLevel != "" {
if err := pp.extendBlossomSubscription(pubkey, satsReceived, blossomLevel, days); err != nil {
log.W.F("failed to extend blossom subscription: %v", err)
// Don't fail the payment if blossom subscription fails
}
}
// Record payment history
invoice, _ := notification["invoice"].(string)
preimage, _ := notification["preimage"].(string)
@@ -888,6 +904,118 @@ func (pp *PaymentProcessor) npubToPubkey(npubStr string) ([]byte, error) {
return pubkey, nil
}
// parseBlossomServiceLevel parses the zap memo for a blossom service level specification
// Format: "blossom:level" or "blossom:level:storage_mb" in description or metadata memo field
func (pp *PaymentProcessor) parseBlossomServiceLevel(
description string, metadata map[string]any,
) string {
// Check metadata memo field first
if metadata != nil {
if memo, ok := metadata["memo"].(string); ok && memo != "" {
if level := pp.extractBlossomLevelFromMemo(memo); level != "" {
return level
}
}
}
// Check description
if description != "" {
if level := pp.extractBlossomLevelFromMemo(description); level != "" {
return level
}
}
return ""
}
// extractBlossomLevelFromMemo extracts blossom service level from memo text
// Supports formats: "blossom:basic", "blossom:premium", "blossom:basic:100"
func (pp *PaymentProcessor) extractBlossomLevelFromMemo(memo string) string {
// Look for "blossom:" prefix
parts := strings.Fields(memo)
for _, part := range parts {
if strings.HasPrefix(part, "blossom:") {
// Extract level name (e.g., "basic", "premium")
levelPart := strings.TrimPrefix(part, "blossom:")
// Remove any storage specification (e.g., ":100")
if colonIdx := strings.Index(levelPart, ":"); colonIdx > 0 {
levelPart = levelPart[:colonIdx]
}
// Validate level exists in config
if pp.isValidBlossomLevel(levelPart) {
return levelPart
}
}
}
return ""
}
// isValidBlossomLevel checks if a service level is configured
func (pp *PaymentProcessor) isValidBlossomLevel(level string) bool {
if pp.config == nil || pp.config.BlossomServiceLevels == "" {
return false
}
// Parse service levels from config
levels := strings.Split(pp.config.BlossomServiceLevels, ",")
for _, l := range levels {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, level+":") {
return true
}
}
return false
}
// parseServiceLevelStorage parses storage quota in MB per sat per month for a service level
func (pp *PaymentProcessor) parseServiceLevelStorage(level string) (int64, error) {
if pp.config == nil || pp.config.BlossomServiceLevels == "" {
return 0, fmt.Errorf("blossom service levels not configured")
}
levels := strings.Split(pp.config.BlossomServiceLevels, ",")
for _, l := range levels {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, level+":") {
parts := strings.Split(l, ":")
if len(parts) >= 2 {
var storageMB float64
if _, err := fmt.Sscanf(parts[1], "%f", &storageMB); err != nil {
return 0, fmt.Errorf("invalid storage format: %w", err)
}
return int64(storageMB), nil
}
}
}
return 0, fmt.Errorf("service level %s not found", level)
}
// extendBlossomSubscription extends or creates a blossom subscription with service level
func (pp *PaymentProcessor) extendBlossomSubscription(
pubkey []byte, satsReceived int64, level string, days int,
) error {
// Get storage quota per sat per month for this level
storageMBPerSatPerMonth, err := pp.parseServiceLevelStorage(level)
if err != nil {
return fmt.Errorf("failed to parse service level storage: %w", err)
}
// Calculate storage quota: sats * storage_mb_per_sat_per_month * (days / 30)
storageMB := int64(float64(satsReceived) * float64(storageMBPerSatPerMonth) * (float64(days) / 30.0))
// Extend blossom subscription
if err := pp.db.ExtendBlossomSubscription(pubkey, level, storageMB, days); err != nil {
return fmt.Errorf("failed to extend blossom subscription: %w", err)
}
log.I.F(
"extended blossom subscription: level=%s, storage=%d MB, days=%d",
level, storageMB, days,
)
return nil
}
// UpdateRelayProfile creates or updates the relay's kind 0 profile with subscription information
func (pp *PaymentProcessor) UpdateRelayProfile() error {
// Get relay identity secret to sign the profile

View File

@@ -27,6 +27,7 @@ import (
"next.orly.dev/pkg/protocol/httpauth"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/spider"
blossom "next.orly.dev/pkg/blossom"
)
type Server struct {
@@ -49,6 +50,7 @@ type Server struct {
sprocketManager *SprocketManager
policyManager *policy.P
spiderManager *spider.Spider
blossomServer *blossom.Server
}
// isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system
@@ -241,6 +243,12 @@ func (s *Server) UserInterface() {
s.mux.HandleFunc("/api/nip86", s.handleNIP86Management)
// ACL mode endpoint
s.mux.HandleFunc("/api/acl-mode", s.handleACLMode)
// Blossom blob storage API endpoint
if s.blossomServer != nil {
s.mux.HandleFunc("/blossom/", s.blossomHandler)
log.Printf("Blossom blob storage API enabled at /blossom")
}
}
// handleFavicon serves orly-favicon.png as favicon.ico