diff --git a/config/config.go b/config/config.go index 8ae8eb8e..6d63c679 100644 --- a/config/config.go +++ b/config/config.go @@ -114,6 +114,19 @@ type PortalConfig struct { MaxMediaImagesPerPlugin int `mapstructure:"max_media_images_per_plugin" json:"max_media_images_per_plugin,omitempty"` MaxImageSizeBytes int64 `mapstructure:"max_image_size_bytes" json:"max_image_size_bytes,omitempty"` PresignedURLExpiry time.Duration `mapstructure:"presigned_url_expiry" json:"presigned_url_expiry,omitempty"` + Email PortalEmailConfig `mapstructure:"email" json:"email,omitempty"` + DeveloperServiceURL string `mapstructure:"developer_service_url" json:"developer_service_url,omitempty"` +} + +type PortalEmailConfig struct { + MandrillAPIKey string `mapstructure:"mandrill_api_key" json:"mandrill_api_key,omitempty"` + FromEmail string `mapstructure:"from_email" json:"from_email,omitempty"` + FromName string `mapstructure:"from_name" json:"from_name,omitempty"` + NotificationEmails []string `mapstructure:"notification_emails" json:"notification_emails,omitempty"` +} + +func (c PortalEmailConfig) IsConfigured() bool { + return c.MandrillAPIKey != "" && c.FromEmail != "" && len(c.NotificationEmails) > 0 } func GetConfigure() (*WorkerConfig, error) { diff --git a/go.mod b/go.mod index 679351f0..516f5105 100644 --- a/go.mod +++ b/go.mod @@ -177,6 +177,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/ipfs/go-log v1.0.5 // indirect github.com/ipfs/go-log/v2 v2.5.1 // indirect + github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/go.sum b/go.sum index b7d6d391..62f02efd 100644 --- a/go.sum +++ b/go.sum @@ -605,6 +605,8 @@ github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JP github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/internal/api/api.go b/internal/api/api.go index 7f48a9b9..e17c78ee 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -147,6 +147,10 @@ const ( msgInvalidSignatureFormat = "invalid signature format" msgSignatureVerificationFailed = "signature verification failed" + // Proposed plugins + msgProposedPluginValidateFailed = "failed to validate proposed plugin" + msgProposedPluginNotApproved = "proposed plugin not found or not approved" + // General msgInvalidRequestFormat = "invalid request format" msgRequestValidationFailed = "request validation failed" diff --git a/internal/api/proposed_plugin.go b/internal/api/proposed_plugin.go new file mode 100644 index 00000000..dada1e8b --- /dev/null +++ b/internal/api/proposed_plugin.go @@ -0,0 +1,24 @@ +package api + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +func (s *Server) ValidateProposedPlugin(c echo.Context) error { + pluginID := c.Param("pluginId") + if pluginID == "" { + return s.badRequest(c, msgRequiredPluginID, nil) + } + + approved, err := s.db.IsProposedPluginApproved(c.Request().Context(), pluginID) + if err != nil { + return s.internal(c, msgProposedPluginValidateFailed, err) + } + if !approved { + return c.JSON(http.StatusNotFound, NewErrorResponseWithMessage(msgProposedPluginNotApproved)) + } + + return c.JSON(http.StatusOK, NewSuccessResponse(http.StatusOK, map[string]bool{"valid": true})) +} diff --git a/internal/api/server.go b/internal/api/server.go index 4f6951e4..e122b1bc 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -204,6 +204,7 @@ func (s *Server) StartServer() error { pluginsGroup.GET("/:pluginId/skills", s.GetPluginSkills) pluginsGroup.GET("/:pluginId/average-rating", s.GetPluginAvgRating) pluginsGroup.POST("/:pluginId/report", s.ReportPlugin, s.VaultAuthMiddleware) + pluginsGroup.GET("/proposed/validate/:pluginId", s.ValidateProposedPlugin, s.VaultAuthMiddleware) categoriesGroup := e.Group("/categories") categoriesGroup.GET("", s.GetCategories) diff --git a/internal/portal/email.go b/internal/portal/email.go new file mode 100644 index 00000000..d5978422 --- /dev/null +++ b/internal/portal/email.go @@ -0,0 +1,305 @@ +package portal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sirupsen/logrus" + + "github.com/vultisig/verifier/config" +) + +const mandrillSendURL = "https://mandrillapp.com/api/1.0/messages/send.json" + +func maskEmail(email string) string { + at := strings.Index(email, "@") + if at <= 1 { + return "***" + } + return email[:1] + strings.Repeat("*", at-1) + email[at:] +} + +type EmailSender interface { + IsConfigured() bool + SendProposalNotificationAsync(pluginID, title, contactEmail string) + SendApprovalNotificationAsync(pluginID, title, contactEmail string) + SendPublishNotificationAsync(pluginID, title, contactEmail string) +} + +type EmailService struct { + cfg config.PortalEmailConfig + portalURL string + mandrillURL string + client *http.Client + logger *logrus.Logger +} + +func NewEmailService(cfg config.PortalEmailConfig, portalURL string, logger *logrus.Logger) *EmailService { + return &EmailService{ + cfg: cfg, + portalURL: strings.TrimRight(portalURL, "/"), + mandrillURL: mandrillSendURL, + logger: logger, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (s *EmailService) IsConfigured() bool { + return s.cfg.IsConfigured() +} + +type mandrillMessage struct { + Key string `json:"key"` + Message mandrillMessageBody `json:"message"` +} + +type mandrillMessageBody struct { + FromEmail string `json:"from_email"` + FromName string `json:"from_name"` + To []mandrillRecipient `json:"to"` + Subject string `json:"subject"` + HTML string `json:"html"` + Text string `json:"text"` +} + +type mandrillRecipient struct { + Email string `json:"email"` + Type string `json:"type"` +} + +type mandrillSendResult struct { + Email string `json:"email"` + Status string `json:"status"` + RejectReason string `json:"reject_reason,omitempty"` +} + +func (s *EmailService) SendProposalNotificationAsync(pluginID, title, contactEmail string) { + if !s.IsConfigured() { + return + } + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := s.sendProposalNotification(ctx, pluginID, title, contactEmail) + if err != nil { + s.logger.WithError(err).WithFields(logrus.Fields{ + "plugin_id": pluginID, + }).Error("failed to send proposal notification email") + } + }() +} + +func (s *EmailService) sendProposalNotification(ctx context.Context, pluginID, title, contactEmail string) error { + pid := html.EscapeString(pluginID) + t := html.EscapeString(title) + ce := html.EscapeString(contactEmail) + + proposalURL := fmt.Sprintf("%s/admin/plugin-proposals/%s", s.portalURL, url.PathEscape(pluginID)) + + subject := fmt.Sprintf("New Plugin Proposal: %s", t) + htmlBody := fmt.Sprintf(` +

New Plugin Proposal Submitted

+

A new plugin proposal has been submitted for review.

+ + + + + + + + + + + + + +
Plugin ID:%s
Title:%s
Contact Email:%s
+

View proposal in admin portal

+`, pid, t, ce, html.EscapeString(proposalURL)) + + text := fmt.Sprintf(`New Plugin Proposal Submitted + +Plugin ID: %s +Title: %s +Contact Email: %s + +View proposal: %s +`, pluginID, title, contactEmail, proposalURL) + + return s.sendToAdmins(ctx, subject, htmlBody, text) +} + +// TODO: migrate async methods to use Redis/Asynq queue for reliability and retries +func (s *EmailService) SendApprovalNotificationAsync(pluginID, title, contactEmail string) { + if !s.IsConfigured() || contactEmail == "" { + return + } + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := s.sendApprovalNotification(ctx, pluginID, title, contactEmail) + if err != nil { + s.logger.WithError(err).WithFields(logrus.Fields{ + "plugin_id": pluginID, + "email": maskEmail(contactEmail), + }).Error("failed to send approval notification email") + } + }() +} + +func (s *EmailService) sendApprovalNotification(ctx context.Context, pluginID, title, contactEmail string) error { + t := html.EscapeString(title) + + subject := fmt.Sprintf("Your Plugin Proposal Has Been Approved: %s", t) + htmlBody := fmt.Sprintf(` +

Plugin Proposal Approved

+

Your plugin proposal %s has been approved.

+

To complete the listing process:

+
    +
  1. Pay the listing fee through the developer portal
  2. +
  3. Once payment is confirmed, your plugin will be published automatically
  4. +
+

Thank you for contributing to Vultisig!

+`, t) + + text := fmt.Sprintf(`Plugin Proposal Approved + +Your plugin proposal "%s" has been approved. + +To complete the listing process: +1. Pay the listing fee through the developer portal +2. Once payment is confirmed, your plugin will be published automatically + +Thank you for contributing to Vultisig! +`, title) + + return s.sendToRecipient(ctx, contactEmail, subject, htmlBody, text) +} + +func (s *EmailService) SendPublishNotificationAsync(pluginID, title, contactEmail string) { + if !s.IsConfigured() || contactEmail == "" { + return + } + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := s.sendPublishNotification(ctx, pluginID, title, contactEmail) + if err != nil { + s.logger.WithError(err).WithFields(logrus.Fields{ + "plugin_id": pluginID, + "email": maskEmail(contactEmail), + }).Error("failed to send publish notification email") + } + }() +} + +func (s *EmailService) sendPublishNotification(ctx context.Context, pluginID, title, contactEmail string) error { + t := html.EscapeString(title) + pid := html.EscapeString(pluginID) + pluginURL := fmt.Sprintf("%s/plugins/%s", s.portalURL, url.PathEscape(pluginID)) + + subject := fmt.Sprintf("Your Plugin Is Now Live: %s", t) + htmlBody := fmt.Sprintf(` +

Plugin Published!

+

Your plugin %s is now live on the Vultisig marketplace.

+

View your plugin

+

Plugin ID: %s

+

Thank you for contributing to Vultisig!

+`, t, html.EscapeString(pluginURL), pid) + + text := fmt.Sprintf(`Plugin Published! + +Your plugin "%s" is now live on the Vultisig marketplace. + +View your plugin: %s + +Plugin ID: %s + +Thank you for contributing to Vultisig! +`, title, pluginURL, pluginID) + + return s.sendToRecipient(ctx, contactEmail, subject, htmlBody, text) +} + +func (s *EmailService) sendToAdmins(ctx context.Context, subject, htmlBody, text string) error { + recipients := make([]mandrillRecipient, len(s.cfg.NotificationEmails)) + for i, email := range s.cfg.NotificationEmails { + recipients[i] = mandrillRecipient{Email: email, Type: "to"} + } + return s.sendEmail(ctx, recipients, subject, htmlBody, text) +} + +func (s *EmailService) sendToRecipient(ctx context.Context, email, subject, htmlBody, text string) error { + recipients := []mandrillRecipient{{Email: email, Type: "to"}} + return s.sendEmail(ctx, recipients, subject, htmlBody, text) +} + +func (s *EmailService) sendEmail(ctx context.Context, recipients []mandrillRecipient, subject, htmlBody, text string) error { + msg := mandrillMessage{ + Key: s.cfg.MandrillAPIKey, + Message: mandrillMessageBody{ + FromEmail: s.cfg.FromEmail, + FromName: s.cfg.FromName, + To: recipients, + Subject: subject, + HTML: htmlBody, + Text: text, + }, + } + + body, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshal email request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.mandrillURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create email request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("send email request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("mandrill returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var results []mandrillSendResult + err = json.Unmarshal(respBody, &results) + if err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + + for _, r := range results { + if r.Status != "sent" && r.Status != "queued" { + return fmt.Errorf("email to %s failed: %s (%s)", maskEmail(r.Email), r.Status, r.RejectReason) + } + } + + return nil +} diff --git a/internal/portal/email_test.go b/internal/portal/email_test.go new file mode 100644 index 00000000..02db6373 --- /dev/null +++ b/internal/portal/email_test.go @@ -0,0 +1,415 @@ +package portal + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/vultisig/verifier/config" +) + +type MockEmailSender struct { + mu sync.Mutex + ProposalNotifications []EmailNotification + ApprovalNotifications []EmailNotification + PublishNotifications []EmailNotification + configured bool + SendProposalNotificationFn func(pluginID, title, contactEmail string) + SendApprovalNotificationFn func(pluginID, title, contactEmail string) + SendPublishNotificationFn func(pluginID, title, contactEmail string) +} + +type EmailNotification struct { + PluginID string + Title string + ContactEmail string +} + +func NewMockEmailSender(configured bool) *MockEmailSender { + return &MockEmailSender{ + configured: configured, + } +} + +func (m *MockEmailSender) IsConfigured() bool { + return m.configured +} + +func (m *MockEmailSender) SendProposalNotificationAsync(pluginID, title, contactEmail string) { + if m.SendProposalNotificationFn != nil { + m.SendProposalNotificationFn(pluginID, title, contactEmail) + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.ProposalNotifications = append(m.ProposalNotifications, EmailNotification{ + PluginID: pluginID, + Title: title, + ContactEmail: contactEmail, + }) +} + +func (m *MockEmailSender) SendApprovalNotificationAsync(pluginID, title, contactEmail string) { + if m.SendApprovalNotificationFn != nil { + m.SendApprovalNotificationFn(pluginID, title, contactEmail) + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.ApprovalNotifications = append(m.ApprovalNotifications, EmailNotification{ + PluginID: pluginID, + Title: title, + ContactEmail: contactEmail, + }) +} + +func (m *MockEmailSender) SendPublishNotificationAsync(pluginID, title, contactEmail string) { + if m.SendPublishNotificationFn != nil { + m.SendPublishNotificationFn(pluginID, title, contactEmail) + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.PublishNotifications = append(m.PublishNotifications, EmailNotification{ + PluginID: pluginID, + Title: title, + ContactEmail: contactEmail, + }) +} + +func (m *MockEmailSender) GetProposalNotifications() []EmailNotification { + m.mu.Lock() + defer m.mu.Unlock() + return append([]EmailNotification{}, m.ProposalNotifications...) +} + +func (m *MockEmailSender) GetApprovalNotifications() []EmailNotification { + m.mu.Lock() + defer m.mu.Unlock() + return append([]EmailNotification{}, m.ApprovalNotifications...) +} + +func (m *MockEmailSender) GetPublishNotifications() []EmailNotification { + m.mu.Lock() + defer m.mu.Unlock() + return append([]EmailNotification{}, m.PublishNotifications...) +} + +func TestMockEmailSender_Interface(t *testing.T) { + var _ EmailSender = (*MockEmailSender)(nil) + var _ EmailSender = (*EmailService)(nil) +} + +func TestMockEmailSender_SendProposalNotification(t *testing.T) { + mock := NewMockEmailSender(true) + + mock.SendProposalNotificationAsync("test-plugin-001", "Test Plugin", "dev@example.com") + + notifications := mock.GetProposalNotifications() + if len(notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(notifications)) + } + + n := notifications[0] + if n.PluginID != "test-plugin-001" { + t.Errorf("expected pluginID 'test-plugin-001', got '%s'", n.PluginID) + } + if n.Title != "Test Plugin" { + t.Errorf("expected title 'Test Plugin', got '%s'", n.Title) + } + if n.ContactEmail != "dev@example.com" { + t.Errorf("expected contactEmail 'dev@example.com', got '%s'", n.ContactEmail) + } +} + +func TestMockEmailSender_SendApprovalNotification(t *testing.T) { + mock := NewMockEmailSender(true) + + mock.SendApprovalNotificationAsync("test-plugin-002", "Approved Plugin", "approved@example.com") + + notifications := mock.GetApprovalNotifications() + if len(notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(notifications)) + } + + n := notifications[0] + if n.PluginID != "test-plugin-002" { + t.Errorf("expected pluginID 'test-plugin-002', got '%s'", n.PluginID) + } + if n.Title != "Approved Plugin" { + t.Errorf("expected title 'Approved Plugin', got '%s'", n.Title) + } + if n.ContactEmail != "approved@example.com" { + t.Errorf("expected contactEmail 'approved@example.com', got '%s'", n.ContactEmail) + } +} + +func TestMockEmailSender_SendPublishNotification(t *testing.T) { + mock := NewMockEmailSender(true) + + mock.SendPublishNotificationAsync("test-plugin-003", "Published Plugin", "published@example.com") + + notifications := mock.GetPublishNotifications() + if len(notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(notifications)) + } + + n := notifications[0] + if n.PluginID != "test-plugin-003" { + t.Errorf("expected pluginID 'test-plugin-003', got '%s'", n.PluginID) + } + if n.Title != "Published Plugin" { + t.Errorf("expected title 'Published Plugin', got '%s'", n.Title) + } + if n.ContactEmail != "published@example.com" { + t.Errorf("expected contactEmail 'published@example.com', got '%s'", n.ContactEmail) + } +} + +func TestEmailService_IsConfigured(t *testing.T) { + tests := []struct { + name string + cfg config.PortalEmailConfig + expected bool + }{ + { + name: "not configured - empty config", + cfg: config.PortalEmailConfig{}, + expected: false, + }, + { + name: "not configured - missing api key", + cfg: config.PortalEmailConfig{ + FromEmail: "noreply@vultisig.com", + NotificationEmails: []string{"admin@vultisig.com"}, + }, + expected: false, + }, + { + name: "not configured - missing from email", + cfg: config.PortalEmailConfig{ + MandrillAPIKey: "test-api-key", + NotificationEmails: []string{"admin@vultisig.com"}, + }, + expected: false, + }, + { + name: "not configured - missing notification emails", + cfg: config.PortalEmailConfig{ + MandrillAPIKey: "test-api-key", + FromEmail: "noreply@vultisig.com", + }, + expected: false, + }, + { + name: "configured", + cfg: config.PortalEmailConfig{ + MandrillAPIKey: "test-api-key", + FromEmail: "noreply@vultisig.com", + FromName: "Vultisig", + NotificationEmails: []string{"admin@vultisig.com"}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewEmailService(tt.cfg, "https://portal.vultisig.com", logrus.New()) + if svc.IsConfigured() != tt.expected { + t.Errorf("IsConfigured() = %v, expected %v", svc.IsConfigured(), tt.expected) + } + }) + } +} + +func TestEmailService_SendProposalNotification_NotConfigured(t *testing.T) { + svc := NewEmailService(config.PortalEmailConfig{}, "https://portal.vultisig.com", logrus.New()) + + svc.SendProposalNotificationAsync("test-plugin", "Test", "test@example.com") +} + +func TestEmailService_SendApprovalNotification_EmptyEmail(t *testing.T) { + cfg := config.PortalEmailConfig{ + MandrillAPIKey: "test-api-key", + FromEmail: "noreply@vultisig.com", + NotificationEmails: []string{"admin@vultisig.com"}, + } + svc := NewEmailService(cfg, "https://portal.vultisig.com", logrus.New()) + + svc.SendApprovalNotificationAsync("test-plugin", "Test", "") +} + +func TestEmailService_SendPublishNotification_EmptyEmail(t *testing.T) { + cfg := config.PortalEmailConfig{ + MandrillAPIKey: "test-api-key", + FromEmail: "noreply@vultisig.com", + NotificationEmails: []string{"admin@vultisig.com"}, + } + svc := NewEmailService(cfg, "https://portal.vultisig.com", logrus.New()) + + svc.SendPublishNotificationAsync("test-plugin", "Test", "") +} + +func TestEmailService_SendEmail_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + + var msg mandrillMessage + err := json.NewDecoder(r.Body).Decode(&msg) + if err != nil { + t.Errorf("failed to decode request: %v", err) + } + + if msg.Key != "test-api-key" { + t.Errorf("expected API key 'test-api-key', got '%s'", msg.Key) + } + if msg.Message.FromEmail != "noreply@vultisig.com" { + t.Errorf("expected from email 'noreply@vultisig.com', got '%s'", msg.Message.FromEmail) + } + if len(msg.Message.To) != 1 { + t.Errorf("expected 1 recipient, got %d", len(msg.Message.To)) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]mandrillSendResult{ + {Email: msg.Message.To[0].Email, Status: "sent"}, + }) + })) + defer server.Close() + + cfg := config.PortalEmailConfig{ + MandrillAPIKey: "test-api-key", + FromEmail: "noreply@vultisig.com", + FromName: "Vultisig", + NotificationEmails: []string{"admin@vultisig.com"}, + } + svc := NewEmailService(cfg, "https://portal.vultisig.com", logrus.New()) + svc.client = server.Client() + + originalURL := mandrillSendURL + defer func() { + if mandrillSendURL != originalURL { + t.Error("mandrillSendURL should not be changed") + } + }() + + ctx := context.Background() + recipients := []mandrillRecipient{{Email: "test@example.com", Type: "to"}} + + err := svc.sendEmailTo(ctx, server.URL, recipients, "Test Subject", "

HTML

", "Text") + if err != nil { + t.Errorf("sendEmailTo failed: %v", err) + } +} + +func TestEmailService_SendEmail_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"status":"error","message":"Invalid API key"}`)) + })) + defer server.Close() + + cfg := config.PortalEmailConfig{ + MandrillAPIKey: "invalid-key", + FromEmail: "noreply@vultisig.com", + NotificationEmails: []string{"admin@vultisig.com"}, + } + svc := NewEmailService(cfg, "https://portal.vultisig.com", logrus.New()) + svc.client = server.Client() + + ctx := context.Background() + recipients := []mandrillRecipient{{Email: "test@example.com", Type: "to"}} + + err := svc.sendEmailTo(ctx, server.URL, recipients, "Test", "

HTML

", "Text") + if err == nil { + t.Error("expected error for invalid API key") + } +} + +func TestEmailService_SendEmail_RejectedEmail(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]mandrillSendResult{ + {Email: "invalid@example.com", Status: "rejected", RejectReason: "invalid-sender"}, + }) + })) + defer server.Close() + + cfg := config.PortalEmailConfig{ + MandrillAPIKey: "test-api-key", + FromEmail: "noreply@vultisig.com", + NotificationEmails: []string{"admin@vultisig.com"}, + } + svc := NewEmailService(cfg, "https://portal.vultisig.com", logrus.New()) + svc.client = server.Client() + + ctx := context.Background() + recipients := []mandrillRecipient{{Email: "invalid@example.com", Type: "to"}} + + err := svc.sendEmailTo(ctx, server.URL, recipients, "Test", "

HTML

", "Text") + if err == nil { + t.Error("expected error for rejected email") + } +} + +func TestEmailService_SendProposalNotification_Async(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer wg.Done() + + var msg mandrillMessage + json.NewDecoder(r.Body).Decode(&msg) + + if msg.Message.Subject == "" { + t.Error("expected non-empty subject") + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]mandrillSendResult{ + {Email: msg.Message.To[0].Email, Status: "queued"}, + }) + })) + defer server.Close() + + cfg := config.PortalEmailConfig{ + MandrillAPIKey: "test-api-key", + FromEmail: "noreply@vultisig.com", + FromName: "Vultisig", + NotificationEmails: []string{"admin@vultisig.com"}, + } + svc := NewEmailService(cfg, "https://portal.vultisig.com", logrus.New()) + svc.client = server.Client() + svc.mandrillURL = server.URL + + svc.SendProposalNotificationAsync("test-plugin", "Test Plugin", "dev@example.com") + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("timeout waiting for async email send") + } +} + +func (s *EmailService) sendEmailTo(ctx context.Context, url string, recipients []mandrillRecipient, subject, htmlBody, text string) error { + s.mandrillURL = url + return s.sendEmail(ctx, recipients, subject, htmlBody, text) +} diff --git a/internal/portal/listing_fee_client.go b/internal/portal/listing_fee_client.go new file mode 100644 index 00000000..aa1d4d3d --- /dev/null +++ b/internal/portal/listing_fee_client.go @@ -0,0 +1,60 @@ +package portal + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +type ListingFeeClient struct { + baseURL string + httpClient *http.Client +} + +func NewListingFeeClient(baseURL string) *ListingFeeClient { + return &ListingFeeClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (c *ListingFeeClient) IsListingFeePaid(ctx context.Context, pluginID string) (bool, error) { + u, err := url.Parse(c.baseURL + "/api/listing-fee/paid") + if err != nil { + return false, fmt.Errorf("invalid base URL: %w", err) + } + + q := u.Query() + q.Set("pluginId", pluginID) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return false, fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return false, fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var result struct { + Paid bool `json:"paid"` + } + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + return false, fmt.Errorf("decode response: %w", err) + } + + return result.Paid, nil +} diff --git a/internal/portal/proposed_plugin_handlers.go b/internal/portal/proposed_plugin_handlers.go new file mode 100644 index 00000000..ae435a08 --- /dev/null +++ b/internal/portal/proposed_plugin_handlers.go @@ -0,0 +1,603 @@ +package portal + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/labstack/echo/v4" + "github.com/sirupsen/logrus" + + itypes "github.com/vultisig/verifier/internal/types" +) + +const ( + maxProposedImageBytes = 2 * 1024 * 1024 + maxActiveProposalsPerDev = 5 + maxMediaImagesProposal = 7 + errPluginIDTaken = "plugin_id is already taken" +) + +var ( + proposalValidate = validator.New() + pluginIDRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + errResponseCommitted = errors.New("response already committed") +) + +func sanitizeValidationError(err error) string { + var ve validator.ValidationErrors + if !errors.As(err, &ve) { + return "validation failed" + } + var msgs []string + for _, fe := range ve { + field := strings.ToLower(fe.Field()) + switch fe.Tag() { + case "required": + msgs = append(msgs, fmt.Sprintf("%s is required", field)) + case "email": + msgs = append(msgs, fmt.Sprintf("%s must be a valid email", field)) + case "max": + msgs = append(msgs, fmt.Sprintf("%s exceeds maximum length", field)) + case "min": + msgs = append(msgs, fmt.Sprintf("%s is too short", field)) + case "oneof": + msgs = append(msgs, fmt.Sprintf("%s has invalid value", field)) + default: + msgs = append(msgs, fmt.Sprintf("%s is invalid", field)) + } + } + return strings.Join(msgs, "; ") +} + +const ( + constraintProposedPluginPK = "proposed_plugins_pkey" + constraintProposedImageOneLogo = "proposed_plugin_images_one_logo" + constraintProposedImageOneBanner = "proposed_plugin_images_one_banner" + constraintProposedImageOneThumb = "proposed_plugin_images_one_thumbnail" +) + +type dbConstraintViolation int + +const ( + violationUnknown dbConstraintViolation = iota + violationPluginIDTaken + violationDuplicateLogo + violationDuplicateBanner + violationDuplicateThumbnail +) + +func classifyProposalConstraintViolation(err error) dbConstraintViolation { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return violationUnknown + } + if pgErr.Code != pgerrcode.UniqueViolation { + return violationUnknown + } + switch pgErr.ConstraintName { + case constraintProposedPluginPK: + return violationPluginIDTaken + case constraintProposedImageOneLogo: + return violationDuplicateLogo + case constraintProposedImageOneBanner: + return violationDuplicateBanner + case constraintProposedImageOneThumb: + return violationDuplicateThumbnail + default: + return violationUnknown + } +} + +func (s *Server) buildImageResponses(images []itypes.ProposedPluginImage) []ProposedPluginImageResponse { + responses := make([]ProposedPluginImageResponse, len(images)) + for i, img := range images { + responses[i] = ProposedPluginImageResponse{ + ID: img.ID.String(), + Type: string(img.ImageType), + URL: s.assetStorage.GetPublicURL(img.S3Path), + ContentType: img.ContentType, + Filename: img.Filename, + ImageOrder: img.ImageOrder, + } + } + return responses +} + +func buildProposedPluginResponse(p itypes.ProposedPlugin, images []ProposedPluginImageResponse) ProposedPluginResponse { + var pricingModelStr *string + if p.PricingModel != nil { + pm := string(*p.PricingModel) + pricingModelStr = &pm + } + return ProposedPluginResponse{ + PluginID: p.PluginID, + Title: p.Title, + ShortDescription: p.ShortDescription, + ServerEndpoint: p.ServerEndpoint, + Category: string(p.Category), + SupportedChains: p.SupportedChains, + PricingModel: pricingModelStr, + ContactEmail: p.ContactEmail, + Notes: p.Notes, + Status: string(p.Status), + Images: images, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } +} + +func (s *Server) requireApprover(c echo.Context) (string, error) { + address, ok := c.Get("address").(string) + if !ok || address == "" { + if err := c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"}); err != nil { + return "", err + } + return "", errResponseCommitted + } + + ctx := c.Request().Context() + isApprover, dbErr := s.queries.IsListingApprover(ctx, address) + if dbErr != nil { + s.logger.WithError(dbErr).Error("failed to check approver status") + if err := c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}); err != nil { + return "", err + } + return "", errResponseCommitted + } + if !isApprover { + if err := c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}); err != nil { + return "", err + } + return "", errResponseCommitted + } + return address, nil +} + +func (s *Server) isPluginIDAvailable(ctx context.Context, pluginID string) (bool, error) { + existsInPlugins, err := s.db.PluginIDExistsInPlugins(ctx, pluginID) + if err != nil { + return false, fmt.Errorf("check plugins: %w", err) + } + if existsInPlugins { + return false, nil + } + + existsInProposals, err := s.db.PluginIDExistsInProposals(ctx, pluginID) + if err != nil { + return false, fmt.Errorf("check proposals: %w", err) + } + return !existsInProposals, nil +} + +func (s *Server) ValidatePluginID(c echo.Context) error { + pluginID := strings.TrimSpace(strings.ToLower(c.Param("id"))) + if pluginID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) + } + + if !pluginIDRegex.MatchString(pluginID) { + return c.JSON(http.StatusOK, ValidatePluginIDResponse{Available: false}) + } + + ctx := c.Request().Context() + + available, err := s.isPluginIDAvailable(ctx, pluginID) + if err != nil { + s.logger.WithError(err).Error("failed to check plugin_id availability") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + return c.JSON(http.StatusOK, ValidatePluginIDResponse{Available: available}) +} + +func (s *Server) CreatePluginProposal(c echo.Context) error { + address, ok := c.Get("address").(string) + if !ok || address == "" { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + } + + ctx := c.Request().Context() + + activeCount, err := s.db.CountActiveProposalsByPublicKey(ctx, address) + if err != nil { + s.logger.WithError(err).Error("failed to count active proposals") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + if activeCount >= maxActiveProposalsPerDev { + return c.JSON(http.StatusConflict, map[string]string{"error": fmt.Sprintf("maximum of %d active proposals reached", maxActiveProposalsPerDev)}) + } + + var req CreateProposedPluginRequest + err = c.Bind(&req) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + } + + req.PluginID = strings.TrimSpace(strings.ToLower(req.PluginID)) + req.Title = strings.TrimSpace(req.Title) + req.ServerEndpoint = strings.TrimRight(strings.TrimSpace(req.ServerEndpoint), "/") + req.ContactEmail = strings.TrimSpace(strings.ToLower(req.ContactEmail)) + req.ShortDescription = strings.TrimSpace(req.ShortDescription) + req.Notes = strings.TrimSpace(req.Notes) + req.PricingModel = strings.TrimSpace(req.PricingModel) + + err = proposalValidate.Struct(req) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": sanitizeValidationError(err)}) + } + + if !pluginIDRegex.MatchString(req.PluginID) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin_id must contain only lowercase letters, numbers, and hyphens"}) + } + + u, err := url.ParseRequestURI(req.ServerEndpoint) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "server_endpoint must be a valid http(s) URL"}) + } + + available, err := s.isPluginIDAvailable(ctx, req.PluginID) + if err != nil { + s.logger.WithError(err).Error("failed to check plugin_id availability") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + if !available { + return c.JSON(http.StatusConflict, map[string]string{"error": errPluginIDTaken}) + } + + logoCount := 0 + bannerCount := 0 + thumbnailCount := 0 + mediaCount := 0 + for _, img := range req.Images { + switch img.Type { + case "logo": + logoCount++ + case "banner": + bannerCount++ + case "thumbnail": + thumbnailCount++ + case "media": + mediaCount++ + } + } + + if logoCount != 1 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "exactly 1 logo image is required"}) + } + if bannerCount > 1 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "at most 1 banner image allowed"}) + } + if thumbnailCount > 1 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "at most 1 thumbnail image allowed"}) + } + if mediaCount > maxMediaImagesProposal { + return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("at most %d media images allowed", maxMediaImagesProposal)}) + } + + type decodedImage struct { + Data []byte + ContentType string + ImageType string + Filename string + Info ImageInfo + } + decodedImages := make([]decodedImage, 0, len(req.Images)) + + for i, img := range req.Images { + estimatedSize := base64.StdEncoding.DecodedLen(len(img.Data)) + if estimatedSize > maxProposedImageBytes { + return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("image %d exceeds maximum size of %dMB", i+1, maxProposedImageBytes/(1024*1024))}) + } + + data, err := base64.StdEncoding.DecodeString(img.Data) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("image %d: invalid base64 data", i+1)}) + } + + if len(data) > maxProposedImageBytes { + return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("image %d exceeds maximum size of %dMB", i+1, maxProposedImageBytes/(1024*1024))}) + } + + info, err := ParseImageInfo(data) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("image %d: %v", i+1, err)}) + } + + if info.MIME != img.ContentType { + return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("image %d: content_type does not match actual image format", i+1)}) + } + + imageType := itypes.PluginImageType(img.Type) + constraints, ok := itypes.ImageTypeConstraints[imageType] + if !ok { + return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("image %d: invalid image type", i+1)}) + } + + if info.Width > constraints.MaxWidth || info.Height > constraints.MaxHeight { + return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("image %d: exceeds maximum dimensions of %dx%d", i+1, constraints.MaxWidth, constraints.MaxHeight)}) + } + + filename := sanitizeFilename(img.Filename) + if filename == "" || filename == "file" { + filename = fmt.Sprintf("%s%s", img.Type, contentTypeToExt(img.ContentType)) + } + + decodedImages = append(decodedImages, decodedImage{ + Data: data, + ContentType: img.ContentType, + ImageType: img.Type, + Filename: filename, + Info: info, + }) + } + + type uploadedImage struct { + S3Path string + ImageType string + ContentType string + Filename string + ID uuid.UUID + Order int + } + uploadedImages := make([]uploadedImage, 0, len(decodedImages)) + mediaOrder := 0 + + cleanup := func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for _, uploaded := range uploadedImages { + if err := s.assetStorage.Delete(cleanupCtx, uploaded.S3Path); err != nil { + s.logger.WithError(err).Warnf("failed to delete S3 object %s during cleanup", uploaded.S3Path) + } + } + } + + for _, img := range decodedImages { + imageID := uuid.New() + ext := contentTypeToExt(img.ContentType) + s3Path := fmt.Sprintf("proposals/%s/%s/%s%s", req.PluginID, img.ImageType, imageID.String(), ext) + + order := 0 + if img.ImageType == "media" { + order = mediaOrder + mediaOrder++ + } + + uploadedImages = append(uploadedImages, uploadedImage{ + S3Path: s3Path, + ImageType: img.ImageType, + ContentType: img.ContentType, + Filename: img.Filename, + ID: imageID, + Order: order, + }) + + err := s.assetStorage.Upload(ctx, s3Path, img.Data, img.ContentType) + if err != nil { + s.logger.WithError(err).Error("failed to upload image to S3") + cleanup() + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to upload images"}) + } + } + + var pricingModel *itypes.ProposedPluginPricing + if req.PricingModel != "" { + pm := itypes.ProposedPluginPricing(req.PricingModel) + pricingModel = &pm + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + s.logger.WithError(err).Error("failed to begin transaction") + cleanup() + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + defer tx.Rollback(ctx) + + proposal, err := s.db.CreateProposedPlugin(ctx, tx, itypes.ProposedPluginCreateParams{ + PluginID: req.PluginID, + PublicKey: address, + Title: req.Title, + ShortDescription: req.ShortDescription, + ServerEndpoint: req.ServerEndpoint, + Category: itypes.PluginCategory(req.Category), + SupportedChains: req.SupportedChains, + PricingModel: pricingModel, + ContactEmail: req.ContactEmail, + Notes: req.Notes, + }) + if err != nil { + cleanup() + violation := classifyProposalConstraintViolation(err) + if violation == violationPluginIDTaken { + s.logger.WithFields(logrus.Fields{"plugin_id": req.PluginID}).Info("plugin_id collision") + return c.JSON(http.StatusConflict, map[string]string{"error": errPluginIDTaken}) + } + s.logger.WithError(err).Error("failed to create proposed plugin") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + imageResponses := make([]ProposedPluginImageResponse, 0, len(uploadedImages)) + for _, img := range uploadedImages { + _, err := s.db.CreateProposedPluginImage(ctx, tx, itypes.ProposedPluginImageCreateParams{ + ID: img.ID, + PluginID: req.PluginID, + ImageType: itypes.PluginImageType(img.ImageType), + S3Path: img.S3Path, + ImageOrder: img.Order, + UploadedByPublicKey: address, + ContentType: img.ContentType, + Filename: img.Filename, + }) + if err != nil { + cleanup() + violation := classifyProposalConstraintViolation(err) + switch violation { + case violationDuplicateLogo: + return c.JSON(http.StatusConflict, map[string]string{"error": "duplicate logo image"}) + case violationDuplicateBanner: + return c.JSON(http.StatusConflict, map[string]string{"error": "duplicate banner image"}) + case violationDuplicateThumbnail: + return c.JSON(http.StatusConflict, map[string]string{"error": "duplicate thumbnail image"}) + default: + s.logger.WithError(err).Error("failed to create proposed plugin image") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save image metadata"}) + } + } + + imageResponses = append(imageResponses, ProposedPluginImageResponse{ + ID: img.ID.String(), + Type: img.ImageType, + URL: s.assetStorage.GetPublicURL(img.S3Path), + ContentType: img.ContentType, + Filename: img.Filename, + ImageOrder: img.Order, + }) + } + + err = tx.Commit(ctx) + if err != nil { + s.logger.WithError(err).Error("failed to commit transaction") + cleanup() + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + s.logger.WithFields(logrus.Fields{ + "plugin_id": req.PluginID, + "owner": address, + "images": len(uploadedImages), + }).Info("proposed plugin created with images") + + // TODO: migrate to Redis/Asynq queue for reliability + s.emailService.SendProposalNotificationAsync(req.PluginID, req.Title, req.ContactEmail) + + return c.JSON(http.StatusCreated, buildProposedPluginResponse(*proposal, imageResponses)) +} + +func (s *Server) GetMyPluginProposal(c echo.Context) error { + pluginID := strings.TrimSpace(strings.ToLower(c.Param("id"))) + if pluginID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) + } + + address, ok := c.Get("address").(string) + if !ok || address == "" { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + } + + ctx := c.Request().Context() + + proposal, err := s.db.GetProposedPluginByOwner(ctx, address, pluginID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "proposed plugin not found"}) + } + s.logger.WithError(err).Error("failed to get proposed plugin") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + images, err := s.db.ListProposedPluginImages(ctx, pluginID) + if err != nil { + s.logger.WithError(err).Error("failed to list proposed plugin images") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + return c.JSON(http.StatusOK, buildProposedPluginResponse(*proposal, s.buildImageResponses(images))) +} + +func (s *Server) GetMyPluginProposals(c echo.Context) error { + address, ok := c.Get("address").(string) + if !ok || address == "" { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + } + + ctx := c.Request().Context() + + proposals, err := s.db.ListProposedPluginsByPublicKey(ctx, address) + if err != nil { + s.logger.WithError(err).Error("failed to list proposed plugins") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + responses := make([]ProposedPluginResponse, len(proposals)) + for i, proposal := range proposals { + images, err := s.db.ListProposedPluginImages(ctx, proposal.PluginID) + if err != nil { + s.logger.WithError(err).Warnf("failed to list images for proposal %s", proposal.PluginID) + images = []itypes.ProposedPluginImage{} + } + responses[i] = buildProposedPluginResponse(proposal, s.buildImageResponses(images)) + } + + return c.JSON(http.StatusOK, responses) +} + +func (s *Server) GetAllPluginProposals(c echo.Context) error { + _, err := s.requireApprover(c) + if err != nil { + return err + } + + ctx := c.Request().Context() + + proposals, err := s.db.ListAllProposedPlugins(ctx) + if err != nil { + s.logger.WithError(err).Error("failed to list all proposed plugins") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + responses := make([]ProposedPluginResponse, len(proposals)) + for i, proposal := range proposals { + images, err := s.db.ListProposedPluginImages(ctx, proposal.PluginID) + if err != nil { + s.logger.WithError(err).Warnf("failed to list images for proposal %s", proposal.PluginID) + images = []itypes.ProposedPluginImage{} + } + responses[i] = buildProposedPluginResponse(proposal, s.buildImageResponses(images)) + } + + return c.JSON(http.StatusOK, responses) +} + +func (s *Server) GetPluginProposal(c echo.Context) error { + pluginID := strings.TrimSpace(strings.ToLower(c.Param("id"))) + if pluginID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) + } + + _, err := s.requireApprover(c) + if err != nil { + return err + } + + ctx := c.Request().Context() + + proposal, err := s.db.GetProposedPlugin(ctx, pluginID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "proposed plugin not found"}) + } + s.logger.WithError(err).Error("failed to get proposed plugin") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + images, err := s.db.ListProposedPluginImages(ctx, pluginID) + if err != nil { + s.logger.WithError(err).Error("failed to list proposed plugin images") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + return c.JSON(http.StatusOK, buildProposedPluginResponse(*proposal, s.buildImageResponses(images))) +} diff --git a/internal/portal/proposed_plugin_types.go b/internal/portal/proposed_plugin_types.go new file mode 100644 index 00000000..55169a70 --- /dev/null +++ b/internal/portal/proposed_plugin_types.go @@ -0,0 +1,52 @@ +package portal + +import "time" + +type ImageData struct { + Type string `json:"type" validate:"required,oneof=logo banner thumbnail media"` + Data string `json:"data" validate:"required"` + ContentType string `json:"content_type" validate:"required,oneof=image/png image/jpeg image/webp"` + Filename string `json:"filename" validate:"omitempty,max=255"` +} + +type CreateProposedPluginRequest struct { + PluginID string `json:"plugin_id" validate:"required,max=64"` + Title string `json:"title" validate:"required,max=255"` + ShortDescription string `json:"short_description" validate:"omitempty,max=500"` + ServerEndpoint string `json:"server_endpoint" validate:"required"` + Category string `json:"category" validate:"required,oneof=ai-agent app"` + SupportedChains []string `json:"supported_chains" validate:"omitempty,dive,max=32"` + PricingModel string `json:"pricing_model" validate:"omitempty,oneof=free per-tx per-install"` + ContactEmail string `json:"contact_email" validate:"required,email,max=255"` + Notes string `json:"notes" validate:"omitempty,max=2000"` + Images []ImageData `json:"images" validate:"required,min=1,max=9,dive"` +} + +type ProposedPluginImageResponse struct { + ID string `json:"id"` + Type string `json:"type"` + URL string `json:"url"` + ContentType string `json:"content_type"` + Filename string `json:"filename"` + ImageOrder int `json:"image_order"` +} + +type ProposedPluginResponse struct { + PluginID string `json:"plugin_id"` + Title string `json:"title"` + ShortDescription string `json:"short_description"` + ServerEndpoint string `json:"server_endpoint"` + Category string `json:"category"` + SupportedChains []string `json:"supported_chains"` + PricingModel *string `json:"pricing_model,omitempty"` + ContactEmail string `json:"contact_email"` + Notes string `json:"notes"` + Status string `json:"status"` + Images []ProposedPluginImageResponse `json:"images"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ValidatePluginIDResponse struct { + Available bool `json:"available"` +} diff --git a/internal/portal/server.go b/internal/portal/server.go index 1d6633ff..14fed101 100644 --- a/internal/portal/server.go +++ b/internal/portal/server.go @@ -1,6 +1,7 @@ package portal import ( + "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -31,27 +32,35 @@ import ( ) type Server struct { - cfg config.PortalConfig - pool *pgxpool.Pool - queries *queries.Queries - logger *logrus.Logger - authService *PortalAuthService - inviteService *InviteService - db *postgres.PostgresBackend - assetStorage storage.PluginAssetStorage + cfg config.PortalConfig + pool *pgxpool.Pool + queries *queries.Queries + logger *logrus.Logger + authService *PortalAuthService + inviteService *InviteService + db *postgres.PostgresBackend + assetStorage storage.PluginAssetStorage + emailService EmailSender + listingFeeClient *ListingFeeClient } func NewServer(cfg config.PortalConfig, pool *pgxpool.Pool, db *postgres.PostgresBackend, assetStorage storage.PluginAssetStorage) *Server { logger := logrus.WithField("service", "portal").Logger + var listingFeeClient *ListingFeeClient + if cfg.DeveloperServiceURL != "" { + listingFeeClient = NewListingFeeClient(cfg.DeveloperServiceURL) + } return &Server{ - cfg: cfg, - pool: pool, - queries: queries.New(pool), - logger: logger, - authService: NewPortalAuthService(cfg.Server.JWTSecret, logger), - inviteService: NewInviteService(cfg.Server.HMACSecret, cfg.Server.BaseURL), - db: db, - assetStorage: assetStorage, + cfg: cfg, + pool: pool, + queries: queries.New(pool), + logger: logger, + authService: NewPortalAuthService(cfg.Server.JWTSecret, logger), + inviteService: NewInviteService(cfg.Server.HMACSecret, cfg.Server.BaseURL), + db: db, + assetStorage: assetStorage, + emailService: NewEmailService(cfg.Email, cfg.Server.BaseURL, logger), + listingFeeClient: listingFeeClient, } } @@ -62,6 +71,7 @@ func (s *Server) Start() error { e.Use(middleware.Logger()) e.Use(middleware.Recover()) e.Use(middleware.CORS()) + e.Use(middleware.BodyLimit("35M")) s.registerRoutes(e) @@ -90,6 +100,16 @@ func (s *Server) registerRoutes(e *echo.Echo) { protected.GET("/plugins/:id", s.GetPlugin) protected.GET("/plugins/:id/my-role", s.GetMyPluginRole) protected.PUT("/plugins/:id", s.UpdatePlugin) + // Plugin proposals (developer) + protected.GET("/plugin-proposals/validate/:id", s.ValidatePluginID) + protected.POST("/plugin-proposals", s.CreatePluginProposal) + protected.GET("/plugin-proposals", s.GetMyPluginProposals) + protected.GET("/plugin-proposals/:id", s.GetMyPluginProposal) + // Plugin proposals (admin) + protected.GET("/admin/plugin-proposals", s.GetAllPluginProposals) + protected.GET("/admin/plugin-proposals/:id", s.GetPluginProposal) + protected.POST("/admin/plugin-proposals/:id/approve", s.ApprovePluginProposal) + protected.POST("/admin/plugin-proposals/:id/publish", s.PublishPluginProposal) // API key management protected.GET("/plugins/:id/api-keys", s.GetPluginApiKeys) protected.POST("/plugins/:id/api-keys", s.CreatePluginApiKey) @@ -238,7 +258,7 @@ func (s *Server) Auth(c echo.Context) error { func (s *Server) GetPlugin(c echo.Context) error { id := c.Param("id") if id == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } // Get address from JWT context (set by JWTAuthMiddleware) @@ -273,7 +293,7 @@ type MyRoleResponse struct { func (s *Server) GetMyPluginRole(c echo.Context) error { id := c.Param("id") if id == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } // Get address from JWT context @@ -335,7 +355,7 @@ type PluginPricingResponse struct { func (s *Server) GetPluginPricings(c echo.Context) error { id := c.Param("id") if id == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } pricings, err := s.queries.GetPluginPricings(c.Request().Context(), id) @@ -379,7 +399,7 @@ type PluginApiKeyResponse struct { func (s *Server) GetPluginApiKeys(c echo.Context) error { id := c.Param("id") if id == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } // Get address from JWT context (set by JWTAuthMiddleware) @@ -451,6 +471,17 @@ func maskApiKey(key string) string { return key[:4] + "..." + key[len(key)-4:] } +// maskPayoutAddress masks a payout address showing first 6 and last 4 chars +func maskPayoutAddress(address string) string { + if address == "" { + return "" + } + if len(address) == 42 && strings.HasPrefix(address, "0x") { + return address[:6] + "..." + address[len(address)-4:] + } + return address +} + // generateApiKey generates a random API key with vbt_ prefix and 32 bytes hex func generateApiKey() (string, error) { bytes := make([]byte, 32) @@ -479,7 +510,7 @@ type CreateApiKeyResponse struct { func (s *Server) CreatePluginApiKey(c echo.Context) error { id := c.Param("id") if id == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } // Get address from JWT context @@ -1019,6 +1050,7 @@ type UpdatePluginRequest struct { Title string `json:"title"` Description string `json:"description"` ServerEndpoint string `json:"server_endpoint"` + PayoutAddress string `json:"payout_address"` // EIP-712 signature data Signature string `json:"signature"` @@ -1037,7 +1069,7 @@ type UpdatePluginSignedMessage struct { func (s *Server) UpdatePlugin(c echo.Context) error { id := c.Param("id") if id == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } var req UpdatePluginRequest @@ -1169,12 +1201,55 @@ func (s *Server) UpdatePlugin(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "server_endpoint change must be signed"}) } + // Validate payout address field + normalizedReqPayout := "" + if req.PayoutAddress != "" { + if !common.IsHexAddress(req.PayoutAddress) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid payout address format"}) + } + normalizedReqPayout = common.HexToAddress(req.PayoutAddress).Hex() + } + normalizedExistingPayout := "" + if existingPlugin.PayoutAddress.Valid && existingPlugin.PayoutAddress.String != "" { + normalizedExistingPayout = common.HexToAddress(existingPlugin.PayoutAddress.String).Hex() + } + + if fieldUpdate, ok := updateMap["payoutAddress"]; ok { + // Only admins can modify payout address + if owner.Role != queries.PluginOwnerRoleAdmin { + return c.JSON(http.StatusForbidden, map[string]string{"error": "only admins can modify payout address"}) + } + // Verify signed values match + if fieldUpdate.NewValue != normalizedReqPayout { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "payout address does not match signed value"}) + } + if fieldUpdate.OldValue != normalizedExistingPayout { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "payout address old value does not match current value"}) + } + } else if normalizedReqPayout != "" && normalizedReqPayout != normalizedExistingPayout { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "payout address change must be signed"}) + } + + // Normalize payout address before storing + var payoutAddress pgtype.Text + if normalizedReqPayout != "" { + payoutAddress = pgtype.Text{ + String: normalizedReqPayout, + Valid: true, + } + } else if _, isUpdating := updateMap["payoutAddress"]; isUpdating { + payoutAddress = pgtype.Text{Valid: false} + } else { + payoutAddress = existingPlugin.PayoutAddress + } + // Update the plugin with validated request values plugin, err := s.queries.UpdatePlugin(c.Request().Context(), &queries.UpdatePluginParams{ ID: id, Title: req.Title, Description: req.Description, ServerEndpoint: req.ServerEndpoint, + PayoutAddress: payoutAddress, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -1184,10 +1259,19 @@ func (s *Server) UpdatePlugin(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) } - s.logger.WithFields(logrus.Fields{ + logFields := logrus.Fields{ "plugin_id": id, "signer": req.SignedMessage.Signer, - }).Info("plugin updated successfully") + } + + if _, ok := updateMap["payoutAddress"]; ok { + logFields["payout_address_changed"] = true + if plugin.PayoutAddress.Valid && plugin.PayoutAddress.String != "" { + logFields["new_payout_address"] = maskPayoutAddress(plugin.PayoutAddress.String) + } + } + + s.logger.WithFields(logFields).Info("plugin updated successfully") return c.JSON(http.StatusOK, plugin) } @@ -1206,7 +1290,7 @@ type TeamMemberResponse struct { func (s *Server) ListTeamMembers(c echo.Context) error { pluginID := c.Param("id") if pluginID == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } // Get address from JWT context @@ -1276,7 +1360,7 @@ type CreateInviteResponse struct { func (s *Server) CreateInvite(c echo.Context) error { pluginID := c.Param("id") if pluginID == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } // Get address from JWT context @@ -1412,7 +1496,7 @@ type AcceptInviteRequest struct { func (s *Server) AcceptInvite(c echo.Context) error { pluginID := c.Param("id") if pluginID == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } // Get address from JWT context (user must be authenticated) @@ -1597,7 +1681,7 @@ type KillSwitchResponse struct { func (s *Server) GetKillSwitch(c echo.Context) error { pluginID := c.Param("id") if pluginID == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } // Get address from JWT context @@ -1668,7 +1752,7 @@ type SetKillSwitchRequest struct { func (s *Server) SetKillSwitch(c echo.Context) error { pluginID := c.Param("id") if pluginID == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "plugin id is required"}) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) } // Get address from JWT context @@ -1766,3 +1850,209 @@ func (s *Server) SetKillSwitch(c echo.Context) error { KeysignEnabled: keysignEnabled, }) } + +type ApprovePluginProposalRequest struct { + PublicKey string `json:"publicKey"` +} + +func (s *Server) ApprovePluginProposal(c echo.Context) error { + id := c.Param("id") + if id == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) + } + + address, err := s.requireApprover(c) + if err != nil { + return err + } + + var req ApprovePluginProposalRequest + err = c.Bind(&req) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + } + if req.PublicKey == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "publicKey is required"}) + } + + ctx := c.Request().Context() + + proposal, err := s.queries.UpdateProposedPluginStatus(ctx, &queries.UpdateProposedPluginStatusParams{ + PublicKey: req.PublicKey, + PluginID: id, + NewStatus: queries.ProposedPluginStatusApproved, + CurrentStatus: queries.ProposedPluginStatusSubmitted, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusConflict, map[string]string{"error": "proposed plugin not found or not in submitted status"}) + } + s.logger.WithError(err).Error("failed to approve proposed plugin") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to approve proposed plugin"}) + } + + s.logger.WithFields(logrus.Fields{ + "plugin_id": id, + "approver": address, + }).Info("proposed plugin approved") + + s.emailService.SendApprovalNotificationAsync(proposal.PluginID, proposal.Title, proposal.ContactEmail) + + return c.JSON(http.StatusOK, proposal) +} + +func (s *Server) PublishPluginProposal(c echo.Context) error { + pluginID := c.Param("id") + if pluginID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) + } + + address, err := s.requireApprover(c) + if err != nil { + return err + } + + ctx := c.Request().Context() + + proposal, err := s.queries.GetProposedPluginByID(ctx, pluginID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "proposed plugin not found"}) + } + s.logger.WithError(err).Error("failed to get proposed plugin") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + if proposal.Status != queries.ProposedPluginStatusApproved { + return c.JSON(http.StatusConflict, map[string]string{"error": "proposed plugin is not in approved status"}) + } + + if s.listingFeeClient != nil { + paid, err := s.listingFeeClient.IsListingFeePaid(ctx, pluginID) + if err != nil { + s.logger.WithError(err).Error("failed to check listing fee") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to verify listing fee payment"}) + } + if !paid { + return c.JSON(http.StatusPaymentRequired, map[string]string{"error": "listing fee not paid"}) + } + } + + proposedImages, err := s.queries.ListProposedPluginImages(ctx, pluginID) + if err != nil { + s.logger.WithError(err).Error("failed to list proposed plugin images") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + type copiedImage struct { + OldPath string + NewPath string + Image *queries.ProposedPluginImage + } + copiedImages := make([]copiedImage, 0, len(proposedImages)) + + rollbackS3 := func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for _, img := range copiedImages { + if err := s.assetStorage.Delete(cleanupCtx, img.NewPath); err != nil { + s.logger.WithError(err).Warnf("failed to delete S3 object %s during rollback", img.NewPath) + } + } + } + + for _, img := range proposedImages { + newPath := strings.Replace(img.S3Path, "proposals/", "plugins/", 1) + err = s.assetStorage.Copy(ctx, img.S3Path, newPath) + if err != nil { + s.logger.WithError(err).Errorf("failed to copy S3 image %s -> %s", img.S3Path, newPath) + rollbackS3() + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to migrate images"}) + } + copiedImages = append(copiedImages, copiedImage{ + OldPath: img.S3Path, + NewPath: newPath, + Image: img, + }) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + s.logger.WithError(err).Error("failed to begin transaction") + rollbackS3() + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + defer tx.Rollback(ctx) + + q := queries.New(tx) + + plugin, err := q.PublishPlugin(ctx, pluginID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + rollbackS3() + return c.JSON(http.StatusConflict, map[string]string{"error": "proposed plugin not found or not in approved status"}) + } + s.logger.WithError(err).Error("failed to publish plugin") + rollbackS3() + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to publish plugin"}) + } + + for _, ci := range copiedImages { + err = q.InsertPluginImage(ctx, &queries.InsertPluginImageParams{ + ID: ci.Image.ID, + PluginID: pluginID, + ImageType: ci.Image.ImageType, + S3Path: ci.NewPath, + ImageOrder: ci.Image.ImageOrder, + UploadedByPublicKey: ci.Image.UploadedByPublicKey, + Visible: ci.Image.Visible, + Deleted: ci.Image.Deleted, + ContentType: ci.Image.ContentType, + Filename: ci.Image.Filename, + CreatedAt: ci.Image.CreatedAt, + }) + if err != nil { + s.logger.WithError(err).Errorf("failed to insert plugin image %s", ci.Image.ID) + rollbackS3() + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to migrate images"}) + } + } + + _, err = q.CreatePluginOwnerFromPortal(ctx, &queries.CreatePluginOwnerFromPortalParams{ + PluginID: pluginID, + PublicKey: proposal.PublicKey, + }) + if err != nil { + s.logger.WithError(err).Error("failed to create plugin owner") + rollbackS3() + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create plugin owner"}) + } + + rowsAffected, err := q.MarkProposedPluginListed(ctx, pluginID) + if err != nil { + s.logger.WithError(err).Error("failed to mark proposal as listed") + rollbackS3() + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update proposal status"}) + } + if rowsAffected == 0 { + rollbackS3() + return c.JSON(http.StatusConflict, map[string]string{"error": "proposal not in approved status"}) + } + + err = tx.Commit(ctx) + if err != nil { + s.logger.WithError(err).Error("failed to commit transaction") + rollbackS3() + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + + s.logger.WithFields(logrus.Fields{ + "plugin_id": pluginID, + "published_by": address, + "images": len(copiedImages), + }).Info("plugin published with images") + + s.emailService.SendPublishNotificationAsync(proposal.PluginID, proposal.Title, proposal.ContactEmail) + + return c.JSON(http.StatusOK, plugin) +} diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go index 346dcd17..57405256 100644 --- a/internal/service/auth_test.go +++ b/internal/service/auth_test.go @@ -485,6 +485,11 @@ func (m *MockDatabaseStorage) GetNextMediaOrder(ctx context.Context, pluginID ty return args.Int(0), args.Error(1) } +func (m *MockDatabaseStorage) IsProposedPluginApproved(ctx context.Context, pluginID string) (bool, error) { + args := m.Called(ctx, pluginID) + return args.Bool(0), args.Error(1) +} + func TestGenerateTokenPair(t *testing.T) { testCases := []struct { name string diff --git a/internal/storage/db.go b/internal/storage/db.go index 4c48cd24..abc78c8f 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -29,6 +29,7 @@ type DatabaseStorage interface { VaultTokenRepository PricingRepository PluginRepository + ProposedPluginRepository PluginOwnerRepository PluginImageRepository FeeRepository @@ -89,6 +90,10 @@ type PluginRepository interface { Pool() *pgxpool.Pool } +type ProposedPluginRepository interface { + IsProposedPluginApproved(ctx context.Context, pluginID string) (bool, error) +} + type PluginOwnerRepository interface { IsOwner(ctx context.Context, pluginID types.PluginID, publicKey string) (bool, error) GetPluginsByOwner(ctx context.Context, publicKey string) ([]types.PluginID, error) diff --git a/internal/storage/plugin_assets.go b/internal/storage/plugin_assets.go index ad99a686..b170526e 100644 --- a/internal/storage/plugin_assets.go +++ b/internal/storage/plugin_assets.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net/url" "time" "github.com/aws/aws-sdk-go/aws" @@ -32,6 +33,7 @@ type PluginAssetStorage interface { HeadObject(ctx context.Context, key string) (*ObjectMetadata, error) GetObjectRange(ctx context.Context, key string, rangeStart, rangeEnd int64) ([]byte, error) GetObject(ctx context.Context, key string) (io.ReadCloser, error) + Copy(ctx context.Context, srcKey, dstKey string) error } type S3PluginAssetStorage struct { @@ -180,3 +182,22 @@ func (s *S3PluginAssetStorage) GetObject(ctx context.Context, key string) (io.Re } return out.Body, nil } + +func (s *S3PluginAssetStorage) Copy(ctx context.Context, srcKey, dstKey string) error { + s.logger.Infof("copying plugin asset: %s -> %s, bucket: %s", srcKey, dstKey, s.cfg.Bucket) + + copySource := fmt.Sprintf("%s/%s", s.cfg.Bucket, url.PathEscape(srcKey)) + _, err := s.s3Client.CopyObjectWithContext(ctx, &s3.CopyObjectInput{ + Bucket: aws.String(s.cfg.Bucket), + CopySource: aws.String(copySource), + Key: aws.String(dstKey), + ACL: aws.String("public-read"), + }) + if err != nil { + s.logger.Errorf("failed to copy plugin asset %s -> %s: %v", srcKey, dstKey, err) + return fmt.Errorf("failed to copy plugin asset: %w", err) + } + + s.logger.Infof("plugin asset copied: %s -> %s", srcKey, dstKey) + return nil +} diff --git a/internal/storage/postgres/migrations/verifier/20260212000001_create_proposed_plugins.sql b/internal/storage/postgres/migrations/verifier/20260212000001_create_proposed_plugins.sql new file mode 100644 index 00000000..793bb0b6 --- /dev/null +++ b/internal/storage/postgres/migrations/verifier/20260212000001_create_proposed_plugins.sql @@ -0,0 +1,79 @@ +-- +goose Up +-- +goose StatementBegin + +ALTER TYPE plugin_owner_added_via ADD VALUE IF NOT EXISTS 'portal_create'; + +CREATE TYPE portal_approver_added_via AS ENUM ('bootstrap', 'admin_portal', 'cli'); + +CREATE TABLE portal_approvers ( + public_key TEXT PRIMARY KEY, + active BOOLEAN NOT NULL DEFAULT TRUE, + added_via portal_approver_added_via NOT NULL DEFAULT 'bootstrap', + added_by_public_key TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TYPE proposed_plugin_status AS ENUM ('submitted', 'approved', 'listed', 'archived'); +CREATE TYPE proposed_plugin_pricing AS ENUM ('free', 'per-tx', 'per-install'); + +CREATE TABLE proposed_plugins ( + plugin_id TEXT PRIMARY KEY, + public_key TEXT NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + server_endpoint TEXT NOT NULL, + category plugin_category NOT NULL DEFAULT 'app', + supported_chains TEXT[] NOT NULL DEFAULT '{}', + pricing_model proposed_plugin_pricing, + contact_email TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + status proposed_plugin_status NOT NULL DEFAULT 'submitted', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_proposed_plugins_public_key ON proposed_plugins(public_key); +CREATE INDEX idx_proposed_plugins_status ON proposed_plugins(status); +CREATE INDEX idx_proposed_plugins_public_key_status ON proposed_plugins(public_key, status); + +CREATE TABLE proposed_plugin_images ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + plugin_id TEXT NOT NULL REFERENCES proposed_plugins(plugin_id) ON DELETE CASCADE, + image_type TEXT NOT NULL, + s3_path TEXT NOT NULL, + image_order INT NOT NULL DEFAULT 0, + uploaded_by_public_key TEXT NOT NULL, + visible BOOLEAN NOT NULL DEFAULT TRUE, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + content_type TEXT NOT NULL, + filename TEXT NOT NULL DEFAULT '', + + CONSTRAINT proposed_plugin_images_content_type_check + CHECK (content_type = ANY (ARRAY['image/jpeg','image/png','image/webp'])), + + CONSTRAINT proposed_plugin_images_image_type_check + CHECK (image_type = ANY (ARRAY['logo','banner','thumbnail','media'])) +); + +CREATE INDEX proposed_plugin_images_plugin_id_idx ON proposed_plugin_images(plugin_id); +CREATE INDEX proposed_plugin_images_plugin_id_type_idx ON proposed_plugin_images(plugin_id, image_type); + +-- One logo, one banner, one thumbnail per proposal +CREATE UNIQUE INDEX proposed_plugin_images_one_logo + ON proposed_plugin_images(plugin_id) + WHERE image_type = 'logo' AND deleted = FALSE; + +CREATE UNIQUE INDEX proposed_plugin_images_one_banner + ON proposed_plugin_images(plugin_id) + WHERE image_type = 'banner' AND deleted = FALSE; + +CREATE UNIQUE INDEX proposed_plugin_images_one_thumbnail + ON proposed_plugin_images(plugin_id) + WHERE image_type = 'thumbnail' AND deleted = FALSE; + +-- +goose StatementEnd + +-- +goose Down diff --git a/internal/storage/postgres/migrations/verifier/20260217000000_add_plugin_payout_address.sql b/internal/storage/postgres/migrations/verifier/20260217000000_add_plugin_payout_address.sql new file mode 100644 index 00000000..583cd12b --- /dev/null +++ b/internal/storage/postgres/migrations/verifier/20260217000000_add_plugin_payout_address.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE plugins ADD COLUMN payout_address TEXT; + +CREATE INDEX idx_plugins_payout_address ON plugins(payout_address) +WHERE payout_address IS NOT NULL; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/storage/postgres/plugin.go b/internal/storage/postgres/plugin.go index cd65db8d..4abf5c7b 100644 --- a/internal/storage/postgres/plugin.go +++ b/internal/storage/postgres/plugin.go @@ -309,7 +309,6 @@ func (p *PostgresBackend) FindPlugins( var argsTotal []any currentArgNumber := 1 - // filters filterClause := "WHERE" if filters.Term != nil { queryFilter := fmt.Sprintf( diff --git a/internal/storage/postgres/proposed_plugin.go b/internal/storage/postgres/proposed_plugin.go new file mode 100644 index 00000000..43f0fe02 --- /dev/null +++ b/internal/storage/postgres/proposed_plugin.go @@ -0,0 +1,322 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + + itypes "github.com/vultisig/verifier/internal/types" +) + +func (p *PostgresBackend) CreateProposedPlugin(ctx context.Context, tx pgx.Tx, params itypes.ProposedPluginCreateParams) (*itypes.ProposedPlugin, error) { + query := ` + INSERT INTO proposed_plugins (plugin_id, public_key, title, description, server_endpoint, category, supported_chains, pricing_model, contact_email, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING plugin_id, public_key, title, description, server_endpoint, category, supported_chains, pricing_model, contact_email, notes, status, created_at, updated_at + ` + + var record itypes.ProposedPlugin + err := tx.QueryRow(ctx, query, + params.PluginID, + params.PublicKey, + params.Title, + params.ShortDescription, + params.ServerEndpoint, + string(params.Category), + params.SupportedChains, + params.PricingModel, + params.ContactEmail, + params.Notes, + ).Scan( + &record.PluginID, + &record.PublicKey, + &record.Title, + &record.ShortDescription, + &record.ServerEndpoint, + &record.Category, + &record.SupportedChains, + &record.PricingModel, + &record.ContactEmail, + &record.Notes, + &record.Status, + &record.CreatedAt, + &record.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("create proposed plugin: %w", err) + } + + return &record, nil +} + +func (p *PostgresBackend) CreateProposedPluginImage(ctx context.Context, tx pgx.Tx, params itypes.ProposedPluginImageCreateParams) (*itypes.ProposedPluginImage, error) { + query := ` + INSERT INTO proposed_plugin_images (id, plugin_id, image_type, s3_path, image_order, uploaded_by_public_key, content_type, filename) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, plugin_id, image_type, s3_path, image_order, uploaded_by_public_key, visible, deleted, content_type, filename, created_at, updated_at + ` + + var record itypes.ProposedPluginImage + err := tx.QueryRow(ctx, query, + params.ID, + params.PluginID, + params.ImageType, + params.S3Path, + params.ImageOrder, + params.UploadedByPublicKey, + params.ContentType, + params.Filename, + ).Scan( + &record.ID, + &record.PluginID, + &record.ImageType, + &record.S3Path, + &record.ImageOrder, + &record.UploadedByPublicKey, + &record.Visible, + &record.Deleted, + &record.ContentType, + &record.Filename, + &record.CreatedAt, + &record.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("create proposed plugin image: %w", err) + } + + return &record, nil +} + +func (p *PostgresBackend) GetProposedPluginByOwner(ctx context.Context, publicKey, pluginID string) (*itypes.ProposedPlugin, error) { + query := ` + SELECT plugin_id, public_key, title, description, server_endpoint, category, supported_chains, pricing_model, contact_email, notes, status, created_at, updated_at + FROM proposed_plugins + WHERE public_key = $1 AND plugin_id = $2 + ` + + var record itypes.ProposedPlugin + err := p.pool.QueryRow(ctx, query, publicKey, pluginID).Scan( + &record.PluginID, + &record.PublicKey, + &record.Title, + &record.ShortDescription, + &record.ServerEndpoint, + &record.Category, + &record.SupportedChains, + &record.PricingModel, + &record.ContactEmail, + &record.Notes, + &record.Status, + &record.CreatedAt, + &record.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("get proposed plugin: %w", err) + } + + return &record, nil +} + +func (p *PostgresBackend) ListProposedPluginsByPublicKey(ctx context.Context, publicKey string) ([]itypes.ProposedPlugin, error) { + query := ` + SELECT plugin_id, public_key, title, description, server_endpoint, category, supported_chains, pricing_model, contact_email, notes, status, created_at, updated_at + FROM proposed_plugins + WHERE public_key = $1 + ORDER BY created_at DESC + ` + + rows, err := p.pool.Query(ctx, query, publicKey) + if err != nil { + return nil, fmt.Errorf("list proposed plugins: %w", err) + } + defer rows.Close() + + var records []itypes.ProposedPlugin + for rows.Next() { + var r itypes.ProposedPlugin + err := rows.Scan( + &r.PluginID, + &r.PublicKey, + &r.Title, + &r.ShortDescription, + &r.ServerEndpoint, + &r.Category, + &r.SupportedChains, + &r.PricingModel, + &r.ContactEmail, + &r.Notes, + &r.Status, + &r.CreatedAt, + &r.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scan proposed plugin: %w", err) + } + records = append(records, r) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate proposed plugins: %w", err) + } + + return records, nil +} + +func (p *PostgresBackend) ListProposedPluginImages(ctx context.Context, pluginID string) ([]itypes.ProposedPluginImage, error) { + query := ` + SELECT id, plugin_id, image_type, s3_path, image_order, uploaded_by_public_key, visible, deleted, content_type, filename, created_at, updated_at + FROM proposed_plugin_images + WHERE plugin_id = $1 AND deleted = false AND visible = true + ORDER BY image_type, image_order ASC + ` + + rows, err := p.pool.Query(ctx, query, pluginID) + if err != nil { + return nil, fmt.Errorf("list proposed plugin images: %w", err) + } + defer rows.Close() + + var records []itypes.ProposedPluginImage + for rows.Next() { + var r itypes.ProposedPluginImage + err := rows.Scan( + &r.ID, + &r.PluginID, + &r.ImageType, + &r.S3Path, + &r.ImageOrder, + &r.UploadedByPublicKey, + &r.Visible, + &r.Deleted, + &r.ContentType, + &r.Filename, + &r.CreatedAt, + &r.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scan proposed plugin image: %w", err) + } + records = append(records, r) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate proposed plugin images: %w", err) + } + + return records, nil +} + +func (p *PostgresBackend) PluginIDExistsInPlugins(ctx context.Context, pluginID string) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM plugins WHERE id = $1)` + var exists bool + err := p.pool.QueryRow(ctx, query, pluginID).Scan(&exists) + if err != nil { + return false, fmt.Errorf("check plugin existence: %w", err) + } + return exists, nil +} + +func (p *PostgresBackend) PluginIDExistsInProposals(ctx context.Context, pluginID string) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM proposed_plugins WHERE plugin_id = $1 AND status IN ('submitted', 'approved', 'listed'))` + var exists bool + err := p.pool.QueryRow(ctx, query, pluginID).Scan(&exists) + if err != nil { + return false, fmt.Errorf("check proposed plugin existence: %w", err) + } + return exists, nil +} + +func (p *PostgresBackend) CountActiveProposalsByPublicKey(ctx context.Context, publicKey string) (int, error) { + query := `SELECT COUNT(*) FROM proposed_plugins WHERE public_key = $1 AND status IN ('submitted', 'approved')` + var count int + err := p.pool.QueryRow(ctx, query, publicKey).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count active proposals: %w", err) + } + return count, nil +} + +func (p *PostgresBackend) IsProposedPluginApproved(ctx context.Context, pluginID string) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM proposed_plugins WHERE plugin_id = $1 AND status = 'approved')` + var exists bool + err := p.pool.QueryRow(ctx, query, pluginID).Scan(&exists) + if err != nil { + return false, fmt.Errorf("check proposed plugin approved: %w", err) + } + return exists, nil +} + +func (p *PostgresBackend) GetProposedPlugin(ctx context.Context, pluginID string) (*itypes.ProposedPlugin, error) { + query := ` + SELECT plugin_id, public_key, title, description, server_endpoint, category, supported_chains, pricing_model, contact_email, notes, status, created_at, updated_at + FROM proposed_plugins + WHERE plugin_id = $1 + ` + + var record itypes.ProposedPlugin + err := p.pool.QueryRow(ctx, query, pluginID).Scan( + &record.PluginID, + &record.PublicKey, + &record.Title, + &record.ShortDescription, + &record.ServerEndpoint, + &record.Category, + &record.SupportedChains, + &record.PricingModel, + &record.ContactEmail, + &record.Notes, + &record.Status, + &record.CreatedAt, + &record.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("get proposed plugin: %w", err) + } + + return &record, nil +} + +func (p *PostgresBackend) ListAllProposedPlugins(ctx context.Context) ([]itypes.ProposedPlugin, error) { + query := ` + SELECT plugin_id, public_key, title, description, server_endpoint, category, supported_chains, pricing_model, contact_email, notes, status, created_at, updated_at + FROM proposed_plugins + ORDER BY created_at DESC + ` + + rows, err := p.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("list all proposed plugins: %w", err) + } + defer rows.Close() + + var records []itypes.ProposedPlugin + for rows.Next() { + var r itypes.ProposedPlugin + err := rows.Scan( + &r.PluginID, + &r.PublicKey, + &r.Title, + &r.ShortDescription, + &r.ServerEndpoint, + &r.Category, + &r.SupportedChains, + &r.PricingModel, + &r.ContactEmail, + &r.Notes, + &r.Status, + &r.CreatedAt, + &r.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scan proposed plugin: %w", err) + } + records = append(records, r) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate proposed plugins: %w", err) + } + + return records, nil +} diff --git a/internal/storage/postgres/queries/models.go b/internal/storage/postgres/queries/models.go index bbcdb34d..69a6da51 100644 --- a/internal/storage/postgres/queries/models.go +++ b/internal/storage/postgres/queries/models.go @@ -146,6 +146,7 @@ const ( PluginOwnerAddedViaOwnerApi PluginOwnerAddedVia = "owner_api" PluginOwnerAddedViaAdminCli PluginOwnerAddedVia = "admin_cli" PluginOwnerAddedViaMagicLink PluginOwnerAddedVia = "magic_link" + PluginOwnerAddedViaPortalCreate PluginOwnerAddedVia = "portal_create" ) func (e *PluginOwnerAddedVia) Scan(src interface{}) error { @@ -227,6 +228,49 @@ func (ns NullPluginOwnerRole) Value() (driver.Value, error) { return string(ns.PluginOwnerRole), nil } +type PortalApproverAddedVia string + +const ( + PortalApproverAddedViaBootstrap PortalApproverAddedVia = "bootstrap" + PortalApproverAddedViaAdminPortal PortalApproverAddedVia = "admin_portal" + PortalApproverAddedViaCli PortalApproverAddedVia = "cli" +) + +func (e *PortalApproverAddedVia) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = PortalApproverAddedVia(s) + case string: + *e = PortalApproverAddedVia(s) + default: + return fmt.Errorf("unsupported scan type for PortalApproverAddedVia: %T", src) + } + return nil +} + +type NullPortalApproverAddedVia struct { + PortalApproverAddedVia PortalApproverAddedVia `json:"portal_approver_added_via"` + Valid bool `json:"valid"` // Valid is true if PortalApproverAddedVia is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullPortalApproverAddedVia) Scan(value interface{}) error { + if value == nil { + ns.PortalApproverAddedVia, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.PortalApproverAddedVia.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullPortalApproverAddedVia) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.PortalApproverAddedVia), nil +} + type PricingAsset string const ( @@ -396,6 +440,93 @@ func (ns NullPricingType) Value() (driver.Value, error) { return string(ns.PricingType), nil } +type ProposedPluginPricing string + +const ( + ProposedPluginPricingFree ProposedPluginPricing = "free" + ProposedPluginPricingPerTx ProposedPluginPricing = "per-tx" + ProposedPluginPricingPerInstall ProposedPluginPricing = "per-install" +) + +func (e *ProposedPluginPricing) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ProposedPluginPricing(s) + case string: + *e = ProposedPluginPricing(s) + default: + return fmt.Errorf("unsupported scan type for ProposedPluginPricing: %T", src) + } + return nil +} + +type NullProposedPluginPricing struct { + ProposedPluginPricing ProposedPluginPricing `json:"proposed_plugin_pricing"` + Valid bool `json:"valid"` // Valid is true if ProposedPluginPricing is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullProposedPluginPricing) Scan(value interface{}) error { + if value == nil { + ns.ProposedPluginPricing, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ProposedPluginPricing.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullProposedPluginPricing) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ProposedPluginPricing), nil +} + +type ProposedPluginStatus string + +const ( + ProposedPluginStatusSubmitted ProposedPluginStatus = "submitted" + ProposedPluginStatusApproved ProposedPluginStatus = "approved" + ProposedPluginStatusListed ProposedPluginStatus = "listed" + ProposedPluginStatusArchived ProposedPluginStatus = "archived" +) + +func (e *ProposedPluginStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ProposedPluginStatus(s) + case string: + *e = ProposedPluginStatus(s) + default: + return fmt.Errorf("unsupported scan type for ProposedPluginStatus: %T", src) + } + return nil +} + +type NullProposedPluginStatus struct { + ProposedPluginStatus ProposedPluginStatus `json:"proposed_plugin_status"` + Valid bool `json:"valid"` // Valid is true if ProposedPluginStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullProposedPluginStatus) Scan(value interface{}) error { + if value == nil { + ns.ProposedPluginStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ProposedPluginStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullProposedPluginStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ProposedPluginStatus), nil +} + type TransactionType string const ( @@ -569,6 +700,7 @@ type Plugin struct { Faqs []byte `json:"faqs"` Features []byte `json:"features"` Audited bool `json:"audited"` + PayoutAddress pgtype.Text `json:"payout_address"` } type PluginApikey struct { @@ -689,6 +821,15 @@ type PluginTag struct { TagID pgtype.UUID `json:"tag_id"` } +type PortalApprover struct { + PublicKey string `json:"public_key"` + Active bool `json:"active"` + AddedVia PortalApproverAddedVia `json:"added_via"` + AddedByPublicKey pgtype.Text `json:"added_by_public_key"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Pricing struct { ID pgtype.UUID `json:"id"` Type PricingType `json:"type"` @@ -701,6 +842,37 @@ type Pricing struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProposedPlugin struct { + PluginID string `json:"plugin_id"` + PublicKey string `json:"public_key"` + Title string `json:"title"` + Description string `json:"description"` + ServerEndpoint string `json:"server_endpoint"` + Category PluginCategory `json:"category"` + SupportedChains []string `json:"supported_chains"` + PricingModel NullProposedPluginPricing `json:"pricing_model"` + ContactEmail string `json:"contact_email"` + Notes string `json:"notes"` + Status ProposedPluginStatus `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type ProposedPluginImage struct { + ID pgtype.UUID `json:"id"` + PluginID string `json:"plugin_id"` + ImageType string `json:"image_type"` + S3Path string `json:"s3_path"` + ImageOrder int32 `json:"image_order"` + UploadedByPublicKey string `json:"uploaded_by_public_key"` + Visible bool `json:"visible"` + Deleted bool `json:"deleted"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ContentType string `json:"content_type"` + Filename string `json:"filename"` +} + type Review struct { ID pgtype.UUID `json:"id"` PluginID pgtype.Text `json:"plugin_id"` diff --git a/internal/storage/postgres/queries/plugin_owners.sql.go b/internal/storage/postgres/queries/plugin_owners.sql.go index 3ed91a00..fef01ff1 100644 --- a/internal/storage/postgres/queries/plugin_owners.sql.go +++ b/internal/storage/postgres/queries/plugin_owners.sql.go @@ -14,6 +14,13 @@ import ( const addPluginTeamMember = `-- name: AddPluginTeamMember :one INSERT INTO plugin_owners (plugin_id, public_key, role, added_via, added_by_public_key, link_id) VALUES ($1, $2, $3, 'magic_link', $4, $5) +ON CONFLICT (plugin_id, public_key) DO UPDATE SET + active = true, + role = EXCLUDED.role, + added_via = EXCLUDED.added_via, + added_by_public_key = EXCLUDED.added_by_public_key, + link_id = EXCLUDED.link_id, + updated_at = NOW() RETURNING plugin_id, public_key, active, role, added_via, added_by_public_key, created_at, updated_at, link_id ` @@ -25,7 +32,6 @@ type AddPluginTeamMemberParams struct { LinkID pgtype.UUID `json:"link_id"` } -// Add a new team member via magic link invite func (q *Queries) AddPluginTeamMember(ctx context.Context, arg *AddPluginTeamMemberParams) (*PluginOwner, error) { row := q.db.QueryRow(ctx, addPluginTeamMember, arg.PluginID, @@ -61,6 +67,39 @@ func (q *Queries) CheckLinkIdUsed(ctx context.Context, linkID pgtype.UUID) (bool return used, err } +const createPluginOwnerFromPortal = `-- name: CreatePluginOwnerFromPortal :one +INSERT INTO plugin_owners (plugin_id, public_key, role, added_via) +VALUES ($1, $2, 'admin', 'portal_create') +ON CONFLICT (plugin_id, public_key) DO UPDATE SET + active = true, + role = 'admin', + added_via = 'portal_create', + updated_at = NOW() +RETURNING plugin_id, public_key, active, role, added_via, added_by_public_key, created_at, updated_at, link_id +` + +type CreatePluginOwnerFromPortalParams struct { + PluginID string `json:"plugin_id"` + PublicKey string `json:"public_key"` +} + +func (q *Queries) CreatePluginOwnerFromPortal(ctx context.Context, arg *CreatePluginOwnerFromPortalParams) (*PluginOwner, error) { + row := q.db.QueryRow(ctx, createPluginOwnerFromPortal, arg.PluginID, arg.PublicKey) + var i PluginOwner + err := row.Scan( + &i.PluginID, + &i.PublicKey, + &i.Active, + &i.Role, + &i.AddedVia, + &i.AddedByPublicKey, + &i.CreatedAt, + &i.UpdatedAt, + &i.LinkID, + ) + return &i, err +} + const getPluginOwner = `-- name: GetPluginOwner :one SELECT plugin_id, public_key, active, role, added_via, added_by_public_key, created_at, updated_at, link_id FROM plugin_owners diff --git a/internal/storage/postgres/queries/plugins.sql.go b/internal/storage/postgres/queries/plugins.sql.go index a6089ebe..187ef019 100644 --- a/internal/storage/postgres/queries/plugins.sql.go +++ b/internal/storage/postgres/queries/plugins.sql.go @@ -7,11 +7,13 @@ package queries import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const getPluginByID = `-- name: GetPluginByID :one -SELECT id, title, description, server_endpoint, category, created_at, updated_at, faqs, features, audited FROM plugins +SELECT id, title, description, server_endpoint, category, created_at, updated_at, faqs, features, audited, payout_address FROM plugins WHERE id = $1 ` @@ -30,12 +32,13 @@ func (q *Queries) GetPluginByID(ctx context.Context, id string) (*Plugin, error) &i.Faqs, &i.Features, &i.Audited, + &i.PayoutAddress, ) return &i, err } const getPluginByIDAndOwner = `-- name: GetPluginByIDAndOwner :one -SELECT p.id, p.title, p.description, p.server_endpoint, p.category, p.created_at, p.updated_at, p.faqs, p.features, p.audited FROM plugins p +SELECT p.id, p.title, p.description, p.server_endpoint, p.category, p.created_at, p.updated_at, p.faqs, p.features, p.audited, p.payout_address FROM plugins p JOIN plugin_owners po ON p.id = po.plugin_id WHERE p.id = $1 AND po.public_key = $2 AND po.active = true ` @@ -59,6 +62,7 @@ func (q *Queries) GetPluginByIDAndOwner(ctx context.Context, arg *GetPluginByIDA &i.Faqs, &i.Features, &i.Audited, + &i.PayoutAddress, ) return &i, err } @@ -100,7 +104,7 @@ func (q *Queries) GetPluginPricings(ctx context.Context, pluginID string) ([]*Pr } const listPlugins = `-- name: ListPlugins :many -SELECT id, title, description, server_endpoint, category, created_at, updated_at, faqs, features, audited FROM plugins +SELECT id, title, description, server_endpoint, category, created_at, updated_at, faqs, features, audited, payout_address FROM plugins ORDER BY updated_at DESC ` @@ -124,6 +128,7 @@ func (q *Queries) ListPlugins(ctx context.Context) ([]*Plugin, error) { &i.Faqs, &i.Features, &i.Audited, + &i.PayoutAddress, ); err != nil { return nil, err } @@ -136,7 +141,7 @@ func (q *Queries) ListPlugins(ctx context.Context) ([]*Plugin, error) { } const listPluginsByOwner = `-- name: ListPluginsByOwner :many -SELECT p.id, p.title, p.description, p.server_endpoint, p.category, p.created_at, p.updated_at, p.faqs, p.features, p.audited FROM plugins p +SELECT p.id, p.title, p.description, p.server_endpoint, p.category, p.created_at, p.updated_at, p.faqs, p.features, p.audited, p.payout_address FROM plugins p JOIN plugin_owners po ON p.id = po.plugin_id WHERE po.public_key = $1 AND po.active = true ORDER BY p.updated_at DESC @@ -162,6 +167,7 @@ func (q *Queries) ListPluginsByOwner(ctx context.Context, publicKey string) ([]* &i.Faqs, &i.Features, &i.Audited, + &i.PayoutAddress, ); err != nil { return nil, err } @@ -173,22 +179,50 @@ func (q *Queries) ListPluginsByOwner(ctx context.Context, publicKey string) ([]* return items, nil } +const publishPlugin = `-- name: PublishPlugin :one +INSERT INTO plugins (id, title, description, server_endpoint, category) +SELECT plugin_id, title, description, server_endpoint, category +FROM proposed_plugins +WHERE plugin_id = $1 AND status = 'approved' +RETURNING id, title, description, server_endpoint, category, created_at, updated_at, faqs, features, audited +` + +func (q *Queries) PublishPlugin(ctx context.Context, pluginID string) (*Plugin, error) { + row := q.db.QueryRow(ctx, publishPlugin, pluginID) + var i Plugin + err := row.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.ServerEndpoint, + &i.Category, + &i.CreatedAt, + &i.UpdatedAt, + &i.Faqs, + &i.Features, + &i.Audited, + ) + return &i, err +} + const updatePlugin = `-- name: UpdatePlugin :one UPDATE plugins SET title = $2, description = $3, server_endpoint = $4, + payout_address = $5, updated_at = now() WHERE id = $1 -RETURNING id, title, description, server_endpoint, category, created_at, updated_at, faqs, features, audited +RETURNING id, title, description, server_endpoint, category, created_at, updated_at, faqs, features, audited, payout_address ` type UpdatePluginParams struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - ServerEndpoint string `json:"server_endpoint"` + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + ServerEndpoint string `json:"server_endpoint"` + PayoutAddress pgtype.Text `json:"payout_address"` } func (q *Queries) UpdatePlugin(ctx context.Context, arg *UpdatePluginParams) (*Plugin, error) { @@ -197,6 +231,7 @@ func (q *Queries) UpdatePlugin(ctx context.Context, arg *UpdatePluginParams) (*P arg.Title, arg.Description, arg.ServerEndpoint, + arg.PayoutAddress, ) var i Plugin err := row.Scan( @@ -210,6 +245,7 @@ func (q *Queries) UpdatePlugin(ctx context.Context, arg *UpdatePluginParams) (*P &i.Faqs, &i.Features, &i.Audited, + &i.PayoutAddress, ) return &i, err } diff --git a/internal/storage/postgres/queries/portal_approvers.sql.go b/internal/storage/postgres/queries/portal_approvers.sql.go new file mode 100644 index 00000000..4fa8d8c2 --- /dev/null +++ b/internal/storage/postgres/queries/portal_approvers.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: portal_approvers.sql + +package queries + +import ( + "context" +) + +const getPortalApprover = `-- name: GetPortalApprover :one + +SELECT public_key, active, added_via, added_by_public_key, created_at, updated_at +FROM portal_approvers WHERE public_key = $1 +` + +// Portal Approvers table queries +func (q *Queries) GetPortalApprover(ctx context.Context, publicKey string) (*PortalApprover, error) { + row := q.db.QueryRow(ctx, getPortalApprover, publicKey) + var i PortalApprover + err := row.Scan( + &i.PublicKey, + &i.Active, + &i.AddedVia, + &i.AddedByPublicKey, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const isListingApprover = `-- name: IsListingApprover :one +SELECT EXISTS( + SELECT 1 FROM portal_approvers + WHERE public_key = $1 + AND active = TRUE +) AS is_approver +` + +func (q *Queries) IsListingApprover(ctx context.Context, publicKey string) (bool, error) { + row := q.db.QueryRow(ctx, isListingApprover, publicKey) + var is_approver bool + err := row.Scan(&is_approver) + return is_approver, err +} + +const isPortalAdmin = `-- name: IsPortalAdmin :one +SELECT EXISTS( + SELECT 1 FROM portal_approvers + WHERE public_key = $1 + AND active = TRUE +) AS is_admin +` + +func (q *Queries) IsPortalAdmin(ctx context.Context, publicKey string) (bool, error) { + row := q.db.QueryRow(ctx, isPortalAdmin, publicKey) + var is_admin bool + err := row.Scan(&is_admin) + return is_admin, err +} + +const isStagingApprover = `-- name: IsStagingApprover :one +SELECT EXISTS( + SELECT 1 FROM portal_approvers + WHERE public_key = $1 + AND active = TRUE +) AS is_approver +` + +func (q *Queries) IsStagingApprover(ctx context.Context, publicKey string) (bool, error) { + row := q.db.QueryRow(ctx, isStagingApprover, publicKey) + var is_approver bool + err := row.Scan(&is_approver) + return is_approver, err +} diff --git a/internal/storage/postgres/queries/proposed_plugins.sql.go b/internal/storage/postgres/queries/proposed_plugins.sql.go new file mode 100644 index 00000000..1f277d63 --- /dev/null +++ b/internal/storage/postgres/queries/proposed_plugins.sql.go @@ -0,0 +1,240 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: proposed_plugins.sql + +package queries + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getProposedPlugin = `-- name: GetProposedPlugin :one +SELECT plugin_id, public_key, title, description, server_endpoint, category, supported_chains, pricing_model, contact_email, notes, status, created_at, updated_at FROM proposed_plugins +WHERE public_key = $1 AND plugin_id = $2 +` + +type GetProposedPluginParams struct { + PublicKey string `json:"public_key"` + PluginID string `json:"plugin_id"` +} + +func (q *Queries) GetProposedPlugin(ctx context.Context, arg *GetProposedPluginParams) (*ProposedPlugin, error) { + row := q.db.QueryRow(ctx, getProposedPlugin, arg.PublicKey, arg.PluginID) + var i ProposedPlugin + err := row.Scan( + &i.PluginID, + &i.PublicKey, + &i.Title, + &i.Description, + &i.ServerEndpoint, + &i.Category, + &i.SupportedChains, + &i.PricingModel, + &i.ContactEmail, + &i.Notes, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const getProposedPluginByID = `-- name: GetProposedPluginByID :one +SELECT plugin_id, public_key, title, description, server_endpoint, category, supported_chains, pricing_model, contact_email, notes, status, created_at, updated_at FROM proposed_plugins +WHERE plugin_id = $1 +` + +func (q *Queries) GetProposedPluginByID(ctx context.Context, pluginID string) (*ProposedPlugin, error) { + row := q.db.QueryRow(ctx, getProposedPluginByID, pluginID) + var i ProposedPlugin + err := row.Scan( + &i.PluginID, + &i.PublicKey, + &i.Title, + &i.Description, + &i.ServerEndpoint, + &i.Category, + &i.SupportedChains, + &i.PricingModel, + &i.ContactEmail, + &i.Notes, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const getProposedPluginsByPublicKey = `-- name: GetProposedPluginsByPublicKey :many + +SELECT plugin_id, public_key, title, description, server_endpoint, category, supported_chains, pricing_model, contact_email, notes, status, created_at, updated_at FROM proposed_plugins +WHERE public_key = $1 +ORDER BY created_at DESC +` + +// Proposed plugins table queries +func (q *Queries) GetProposedPluginsByPublicKey(ctx context.Context, publicKey string) ([]*ProposedPlugin, error) { + rows, err := q.db.Query(ctx, getProposedPluginsByPublicKey, publicKey) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ProposedPlugin{} + for rows.Next() { + var i ProposedPlugin + if err := rows.Scan( + &i.PluginID, + &i.PublicKey, + &i.Title, + &i.Description, + &i.ServerEndpoint, + &i.Category, + &i.SupportedChains, + &i.PricingModel, + &i.ContactEmail, + &i.Notes, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertPluginImage = `-- name: InsertPluginImage :exec +INSERT INTO plugin_images (id, plugin_id, image_type, s3_path, image_order, uploaded_by_public_key, visible, deleted, content_type, filename, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) +` + +type InsertPluginImageParams struct { + ID pgtype.UUID `json:"id"` + PluginID string `json:"plugin_id"` + ImageType string `json:"image_type"` + S3Path string `json:"s3_path"` + ImageOrder int32 `json:"image_order"` + UploadedByPublicKey string `json:"uploaded_by_public_key"` + Visible bool `json:"visible"` + Deleted bool `json:"deleted"` + ContentType string `json:"content_type"` + Filename string `json:"filename"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) InsertPluginImage(ctx context.Context, arg *InsertPluginImageParams) error { + _, err := q.db.Exec(ctx, insertPluginImage, + arg.ID, + arg.PluginID, + arg.ImageType, + arg.S3Path, + arg.ImageOrder, + arg.UploadedByPublicKey, + arg.Visible, + arg.Deleted, + arg.ContentType, + arg.Filename, + arg.CreatedAt, + ) + return err +} + +const listProposedPluginImages = `-- name: ListProposedPluginImages :many +SELECT id, plugin_id, image_type, s3_path, image_order, uploaded_by_public_key, visible, deleted, created_at, updated_at, content_type, filename FROM proposed_plugin_images +WHERE plugin_id = $1 AND deleted = false AND visible = true +ORDER BY image_type, image_order ASC +` + +func (q *Queries) ListProposedPluginImages(ctx context.Context, pluginID string) ([]*ProposedPluginImage, error) { + rows, err := q.db.Query(ctx, listProposedPluginImages, pluginID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ProposedPluginImage{} + for rows.Next() { + var i ProposedPluginImage + if err := rows.Scan( + &i.ID, + &i.PluginID, + &i.ImageType, + &i.S3Path, + &i.ImageOrder, + &i.UploadedByPublicKey, + &i.Visible, + &i.Deleted, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContentType, + &i.Filename, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markProposedPluginListed = `-- name: MarkProposedPluginListed :execrows +UPDATE proposed_plugins +SET status = 'listed', updated_at = NOW() +WHERE plugin_id = $1 AND status = 'approved' +` + +func (q *Queries) MarkProposedPluginListed(ctx context.Context, pluginID string) (int64, error) { + result, err := q.db.Exec(ctx, markProposedPluginListed, pluginID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const updateProposedPluginStatus = `-- name: UpdateProposedPluginStatus :one +UPDATE proposed_plugins +SET status = $1, updated_at = NOW() +WHERE public_key = $2 AND plugin_id = $3 AND status = $4 +RETURNING plugin_id, public_key, title, description, server_endpoint, category, supported_chains, pricing_model, contact_email, notes, status, created_at, updated_at +` + +type UpdateProposedPluginStatusParams struct { + NewStatus ProposedPluginStatus `json:"new_status"` + PublicKey string `json:"public_key"` + PluginID string `json:"plugin_id"` + CurrentStatus ProposedPluginStatus `json:"current_status"` +} + +func (q *Queries) UpdateProposedPluginStatus(ctx context.Context, arg *UpdateProposedPluginStatusParams) (*ProposedPlugin, error) { + row := q.db.QueryRow(ctx, updateProposedPluginStatus, + arg.NewStatus, + arg.PublicKey, + arg.PluginID, + arg.CurrentStatus, + ) + var i ProposedPlugin + err := row.Scan( + &i.PluginID, + &i.PublicKey, + &i.Title, + &i.Description, + &i.ServerEndpoint, + &i.Category, + &i.SupportedChains, + &i.PricingModel, + &i.ContactEmail, + &i.Notes, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/internal/storage/postgres/schema/schema.sql b/internal/storage/postgres/schema/schema.sql index 96dd4c90..65566f6a 100644 --- a/internal/storage/postgres/schema/schema.sql +++ b/internal/storage/postgres/schema/schema.sql @@ -22,7 +22,8 @@ CREATE TYPE "plugin_owner_added_via" AS ENUM ( 'bootstrap_plugin_key', 'owner_api', 'admin_cli', - 'magic_link' + 'magic_link', + 'portal_create' ); CREATE TYPE "plugin_owner_role" AS ENUM ( @@ -32,6 +33,12 @@ CREATE TYPE "plugin_owner_role" AS ENUM ( 'viewer' ); +CREATE TYPE "portal_approver_added_via" AS ENUM ( + 'bootstrap', + 'admin_portal', + 'cli' +); + CREATE TYPE "pricing_asset" AS ENUM ( 'usdc' ); @@ -53,6 +60,19 @@ CREATE TYPE "pricing_type" AS ENUM ( 'per-tx' ); +CREATE TYPE "proposed_plugin_pricing" AS ENUM ( + 'free', + 'per-tx', + 'per-install' +); + +CREATE TYPE "proposed_plugin_status" AS ENUM ( + 'submitted', + 'approved', + 'listed', + 'archived' +); + CREATE TYPE "transaction_type" AS ENUM ( 'debit', 'credit' @@ -314,7 +334,17 @@ CREATE TABLE "plugins" ( "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "faqs" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, "features" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, - "audited" boolean DEFAULT false NOT NULL + "audited" boolean DEFAULT false NOT NULL, + "payout_address" "text" +); + +CREATE TABLE "portal_approvers" ( + "public_key" "text" NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "added_via" "portal_approver_added_via" DEFAULT 'bootstrap'::"public"."portal_approver_added_via" NOT NULL, + "added_by_public_key" "text", + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL ); CREATE TABLE "pricings" ( @@ -330,6 +360,39 @@ CREATE TABLE "pricings" ( CONSTRAINT "frequency_check" CHECK (((("type" = 'recurring'::"pricing_type") AND ("frequency" IS NOT NULL)) OR (("type" = ANY (ARRAY['per-tx'::"public"."pricing_type", 'once'::"public"."pricing_type"])) AND ("frequency" IS NULL)))) ); +CREATE TABLE "proposed_plugin_images" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "plugin_id" "text" NOT NULL, + "image_type" "text" NOT NULL, + "s3_path" "text" NOT NULL, + "image_order" integer DEFAULT 0 NOT NULL, + "uploaded_by_public_key" "text" NOT NULL, + "visible" boolean DEFAULT true NOT NULL, + "deleted" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "content_type" "text" NOT NULL, + "filename" "text" DEFAULT ''::"text" NOT NULL, + CONSTRAINT "proposed_plugin_images_content_type_check" CHECK (("content_type" = ANY (ARRAY['image/jpeg'::"text", 'image/png'::"text", 'image/webp'::"text"]))), + CONSTRAINT "proposed_plugin_images_image_type_check" CHECK (("image_type" = ANY (ARRAY['logo'::"text", 'banner'::"text", 'thumbnail'::"text", 'media'::"text"]))) +); + +CREATE TABLE "proposed_plugins" ( + "plugin_id" "text" NOT NULL, + "public_key" "text" NOT NULL, + "title" character varying(255) NOT NULL, + "description" "text" DEFAULT ''::"text" NOT NULL, + "server_endpoint" "text" NOT NULL, + "category" "plugin_category" DEFAULT 'app'::"public"."plugin_category" NOT NULL, + "supported_chains" "text"[] DEFAULT '{}'::"text"[] NOT NULL, + "pricing_model" "proposed_plugin_pricing", + "contact_email" "text" NOT NULL, + "notes" "text" DEFAULT ''::"text" NOT NULL, + "status" "proposed_plugin_status" DEFAULT 'submitted'::"public"."proposed_plugin_status" NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + CREATE TABLE "reviews" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "plugin_id" "plugin_id", @@ -436,9 +499,18 @@ ALTER TABLE ONLY "plugin_tags" ALTER TABLE ONLY "plugins" ADD CONSTRAINT "plugins_pkey" PRIMARY KEY ("id"); +ALTER TABLE ONLY "portal_approvers" + ADD CONSTRAINT "portal_approvers_pkey" PRIMARY KEY ("public_key"); + ALTER TABLE ONLY "pricings" ADD CONSTRAINT "pricings_pkey" PRIMARY KEY ("id"); +ALTER TABLE ONLY "proposed_plugin_images" + ADD CONSTRAINT "proposed_plugin_images_pkey" PRIMARY KEY ("id"); + +ALTER TABLE ONLY "proposed_plugins" + ADD CONSTRAINT "proposed_plugins_pkey" PRIMARY KEY ("plugin_id"); + ALTER TABLE ONLY "reviews" ADD CONSTRAINT "reviews_pkey" PRIMARY KEY ("id"); @@ -511,6 +583,14 @@ CREATE INDEX "idx_plugin_policy_sync_policy_id" ON "plugin_policy_sync" USING "b CREATE INDEX "idx_plugin_reports_window" ON "plugin_reports" USING "btree" ("plugin_id", "last_reported_at" DESC); +CREATE INDEX "idx_plugins_payout_address" ON "plugins" USING "btree" ("payout_address") WHERE ("payout_address" IS NOT NULL); + +CREATE INDEX "idx_proposed_plugins_public_key" ON "proposed_plugins" USING "btree" ("public_key"); + +CREATE INDEX "idx_proposed_plugins_public_key_status" ON "proposed_plugins" USING "btree" ("public_key", "status"); + +CREATE INDEX "idx_proposed_plugins_status" ON "proposed_plugins" USING "btree" ("status"); + CREATE INDEX "idx_reviews_plugin_id" ON "reviews" USING "btree" ("plugin_id"); CREATE INDEX "idx_reviews_public_key" ON "reviews" USING "btree" ("public_key"); @@ -529,6 +609,16 @@ CREATE INDEX "idx_vault_tokens_public_key" ON "vault_tokens" USING "btree" ("pub CREATE INDEX "idx_vault_tokens_token_id" ON "vault_tokens" USING "btree" ("token_id"); +CREATE UNIQUE INDEX "proposed_plugin_images_one_banner" ON "proposed_plugin_images" USING "btree" ("plugin_id") WHERE (("image_type" = 'banner'::"text") AND ("deleted" = false)); + +CREATE UNIQUE INDEX "proposed_plugin_images_one_logo" ON "proposed_plugin_images" USING "btree" ("plugin_id") WHERE (("image_type" = 'logo'::"text") AND ("deleted" = false)); + +CREATE UNIQUE INDEX "proposed_plugin_images_one_thumbnail" ON "proposed_plugin_images" USING "btree" ("plugin_id") WHERE (("image_type" = 'thumbnail'::"text") AND ("deleted" = false)); + +CREATE INDEX "proposed_plugin_images_plugin_id_idx" ON "proposed_plugin_images" USING "btree" ("plugin_id"); + +CREATE INDEX "proposed_plugin_images_plugin_id_type_idx" ON "proposed_plugin_images" USING "btree" ("plugin_id", "image_type"); + CREATE UNIQUE INDEX "unique_fees_policy_per_public_key" ON "plugin_policies" USING "btree" ("plugin_id", "public_key") WHERE (("plugin_id" = 'vultisig-fees-feee'::"text") AND ("active" = true)); CREATE TRIGGER "trg_prevent_billing_update_if_policy_deleted" BEFORE INSERT OR DELETE OR UPDATE ON "plugin_policy_billing" FOR EACH ROW EXECUTE FUNCTION "public"."prevent_billing_update_if_policy_deleted"(); @@ -583,6 +673,9 @@ ALTER TABLE ONLY "plugin_tags" ALTER TABLE ONLY "pricings" ADD CONSTRAINT "pricings_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "plugins"("id") ON DELETE CASCADE; +ALTER TABLE ONLY "proposed_plugin_images" + ADD CONSTRAINT "proposed_plugin_images_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "proposed_plugins"("plugin_id") ON DELETE CASCADE; + ALTER TABLE ONLY "reviews" ADD CONSTRAINT "reviews_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "plugins"("id") ON DELETE CASCADE; diff --git a/internal/storage/postgres/sqlc/plugin_owners.sql b/internal/storage/postgres/sqlc/plugin_owners.sql index 778f6b5c..6453b86d 100644 --- a/internal/storage/postgres/sqlc/plugin_owners.sql +++ b/internal/storage/postgres/sqlc/plugin_owners.sql @@ -31,10 +31,16 @@ WHERE plugin_id = $1 AND public_key = $2 AND active = true; -- name: CheckLinkIdUsed :one SELECT EXISTS(SELECT 1 FROM plugin_owners WHERE link_id = $1) as used; --- Add a new team member via magic link invite -- name: AddPluginTeamMember :one INSERT INTO plugin_owners (plugin_id, public_key, role, added_via, added_by_public_key, link_id) VALUES ($1, $2, $3, 'magic_link', $4, $5) +ON CONFLICT (plugin_id, public_key) DO UPDATE SET + active = true, + role = EXCLUDED.role, + added_via = EXCLUDED.added_via, + added_by_public_key = EXCLUDED.added_by_public_key, + link_id = EXCLUDED.link_id, + updated_at = NOW() RETURNING *; -- Deactivate a team member (soft delete) @@ -42,3 +48,13 @@ RETURNING *; UPDATE plugin_owners SET active = false, updated_at = NOW() WHERE plugin_id = $1 AND public_key = $2 AND role != 'staff'; + +-- name: CreatePluginOwnerFromPortal :one +INSERT INTO plugin_owners (plugin_id, public_key, role, added_via) +VALUES ($1, $2, 'admin', 'portal_create') +ON CONFLICT (plugin_id, public_key) DO UPDATE SET + active = true, + role = 'admin', + added_via = 'portal_create', + updated_at = NOW() +RETURNING *; diff --git a/internal/storage/postgres/sqlc/plugins.sql b/internal/storage/postgres/sqlc/plugins.sql index 0d161f12..1af9ee4b 100644 --- a/internal/storage/postgres/sqlc/plugins.sql +++ b/internal/storage/postgres/sqlc/plugins.sql @@ -25,10 +25,18 @@ SET title = $2, description = $3, server_endpoint = $4, + payout_address = $5, updated_at = now() WHERE id = $1 RETURNING *; +-- name: PublishPlugin :one +INSERT INTO plugins (id, title, description, server_endpoint, category) +SELECT plugin_id, title, description, server_endpoint, category +FROM proposed_plugins +WHERE plugin_id = $1 AND status = 'approved' +RETURNING *; + -- name: GetPluginPricings :many SELECT * FROM pricings WHERE plugin_id = $1 diff --git a/internal/storage/postgres/sqlc/portal_approvers.sql b/internal/storage/postgres/sqlc/portal_approvers.sql new file mode 100644 index 00000000..03864cd3 --- /dev/null +++ b/internal/storage/postgres/sqlc/portal_approvers.sql @@ -0,0 +1,26 @@ +-- Portal Approvers table queries + +-- name: GetPortalApprover :one +SELECT public_key, active, added_via, added_by_public_key, created_at, updated_at +FROM portal_approvers WHERE public_key = $1; + +-- name: IsStagingApprover :one +SELECT EXISTS( + SELECT 1 FROM portal_approvers + WHERE public_key = $1 + AND active = TRUE +) AS is_approver; + +-- name: IsListingApprover :one +SELECT EXISTS( + SELECT 1 FROM portal_approvers + WHERE public_key = $1 + AND active = TRUE +) AS is_approver; + +-- name: IsPortalAdmin :one +SELECT EXISTS( + SELECT 1 FROM portal_approvers + WHERE public_key = $1 + AND active = TRUE +) AS is_admin; diff --git a/internal/storage/postgres/sqlc/proposed_plugins.sql b/internal/storage/postgres/sqlc/proposed_plugins.sql new file mode 100644 index 00000000..31f1d456 --- /dev/null +++ b/internal/storage/postgres/sqlc/proposed_plugins.sql @@ -0,0 +1,34 @@ +-- Proposed plugins table queries + +-- name: GetProposedPluginsByPublicKey :many +SELECT * FROM proposed_plugins +WHERE public_key = $1 +ORDER BY created_at DESC; + +-- name: GetProposedPlugin :one +SELECT * FROM proposed_plugins +WHERE public_key = $1 AND plugin_id = $2; + +-- name: UpdateProposedPluginStatus :one +UPDATE proposed_plugins +SET status = sqlc.arg(new_status), updated_at = NOW() +WHERE public_key = sqlc.arg(public_key) AND plugin_id = sqlc.arg(plugin_id) AND status = sqlc.arg(current_status) +RETURNING *; + +-- name: GetProposedPluginByID :one +SELECT * FROM proposed_plugins +WHERE plugin_id = $1; + +-- name: MarkProposedPluginListed :execrows +UPDATE proposed_plugins +SET status = 'listed', updated_at = NOW() +WHERE plugin_id = $1 AND status = 'approved'; + +-- name: ListProposedPluginImages :many +SELECT * FROM proposed_plugin_images +WHERE plugin_id = $1 AND deleted = false AND visible = true +ORDER BY image_type, image_order ASC; + +-- name: InsertPluginImage :exec +INSERT INTO plugin_images (id, plugin_id, image_type, s3_path, image_order, uploaded_by_public_key, visible, deleted, content_type, filename, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()); diff --git a/internal/types/plugin.go b/internal/types/plugin.go index b0499e92..4f7e0749 100644 --- a/internal/types/plugin.go +++ b/internal/types/plugin.go @@ -27,6 +27,7 @@ type Plugin struct { RatesCount int `json:"rates_count"` AvgRating float64 `json:"avg_rating"` Installations int `json:"installations"` + PayoutAddress string `json:"payout_address,omitempty"` } type FAQItem struct { diff --git a/internal/types/plugin_owner.go b/internal/types/plugin_owner.go index 0f0996b8..519049d6 100644 --- a/internal/types/plugin_owner.go +++ b/internal/types/plugin_owner.go @@ -15,9 +15,11 @@ const ( type PluginOwnerAddedVia string const ( - PluginOwnerAddedViaBootstrap PluginOwnerAddedVia = "bootstrap_plugin_key" - PluginOwnerAddedViaOwnerAPI PluginOwnerAddedVia = "owner_api" - PluginOwnerAddedViaAdminCLI PluginOwnerAddedVia = "admin_cli" + PluginOwnerAddedViaBootstrap PluginOwnerAddedVia = "bootstrap_plugin_key" + PluginOwnerAddedViaOwnerAPI PluginOwnerAddedVia = "owner_api" + PluginOwnerAddedViaAdminCLI PluginOwnerAddedVia = "admin_cli" + PluginOwnerAddedViaMagicLink PluginOwnerAddedVia = "magic_link" + PluginOwnerAddedViaPortalCreate PluginOwnerAddedVia = "portal_create" ) type PluginOwner struct { diff --git a/internal/types/proposed_plugin.go b/internal/types/proposed_plugin.go new file mode 100644 index 00000000..2e449f0f --- /dev/null +++ b/internal/types/proposed_plugin.go @@ -0,0 +1,79 @@ +package types + +import ( + "time" + + "github.com/google/uuid" +) + +type ProposedPluginStatus string + +const ( + ProposedPluginStatusSubmitted ProposedPluginStatus = "submitted" + ProposedPluginStatusApproved ProposedPluginStatus = "approved" + ProposedPluginStatusListed ProposedPluginStatus = "listed" + ProposedPluginStatusArchived ProposedPluginStatus = "archived" +) + +type ProposedPluginPricing string + +const ( + ProposedPluginPricingFree ProposedPluginPricing = "free" + ProposedPluginPricingPerTx ProposedPluginPricing = "per-tx" + ProposedPluginPricingPerInstall ProposedPluginPricing = "per-install" +) + +type ProposedPlugin struct { + PluginID string `json:"plugin_id"` + PublicKey string `json:"public_key"` + Title string `json:"title"` + ShortDescription string `json:"short_description"` + ServerEndpoint string `json:"server_endpoint"` + Category PluginCategory `json:"category"` + SupportedChains []string `json:"supported_chains"` + PricingModel *ProposedPluginPricing `json:"pricing_model"` + ContactEmail string `json:"contact_email"` + Notes string `json:"notes"` + Status ProposedPluginStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProposedPluginCreateParams struct { + PluginID string + PublicKey string + Title string + ShortDescription string + ServerEndpoint string + Category PluginCategory + SupportedChains []string + PricingModel *ProposedPluginPricing + ContactEmail string + Notes string +} + +type ProposedPluginImage struct { + ID uuid.UUID `json:"id"` + PluginID string `json:"plugin_id"` + ImageType PluginImageType `json:"image_type"` + S3Path string `json:"s3_path"` + ImageOrder int `json:"image_order"` + UploadedByPublicKey string `json:"-"` + Visible bool `json:"-"` + Deleted bool `json:"-"` + ContentType string `json:"content_type"` + Filename string `json:"filename"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProposedPluginImageCreateParams struct { + ID uuid.UUID + PluginID string + ImageType PluginImageType + S3Path string + ImageOrder int + UploadedByPublicKey string + ContentType string + Filename string +} diff --git a/testdata/integration/gotest/seeder.go b/testdata/integration/gotest/seeder.go index 841937c0..48c07400 100644 --- a/testdata/integration/gotest/seeder.go +++ b/testdata/integration/gotest/seeder.go @@ -68,8 +68,8 @@ func (s *Seeder) SeedDatabase(ctx context.Context) error { log.Printf(" Inserting plugin: %s...\n", plugin.ID) _, err := tx.Exec(ctx, ` - INSERT INTO plugins (id, title, description, server_endpoint, category, audited) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO plugins (id, title, description, server_endpoint, category, audited, status) + VALUES ($1, $2, $3, $4, $5, $6, 'listed') ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, diff --git a/testdata/seeds/01_plugins.sql b/testdata/seeds/01_plugins.sql index 74d6b807..8e891540 100644 --- a/testdata/seeds/01_plugins.sql +++ b/testdata/seeds/01_plugins.sql @@ -1,8 +1,8 @@ -INSERT INTO plugins (id, title, description, server_endpoint, category) VALUES +INSERT INTO plugins (id, title, description, server_endpoint, category) VALUES -- DCA Plugin with per-transaction pricing ( - 'vultisig-dca-0000', - 'Vultisig DCA Plugin', + 'vultisig-dca-0000', + 'Vultisig DCA Plugin', 'Dollar Cost Averaging automation for cryptocurrency investments. Automatically execute recurring buy orders based on predefined schedules and strategies.', 'http://dca-server:8080', 'app' @@ -11,7 +11,7 @@ INSERT INTO plugins (id, title, description, server_endpoint, category) VALUES -- Payroll Plugin with monthly pricing ( 'vultisig-payroll-0000', - 'Vultisig Payroll Plugin', + 'Vultisig Payroll Plugin', 'Automated payroll system for cryptocurrency payments. Handle employee payments, tax calculations, and compliance reporting.', 'http://payroll-server:8080', 'app' @@ -42,4 +42,4 @@ INSERT INTO plugins (id, title, description, server_endpoint, category) VALUES 'Merkle tree implementation for efficient data storage and retrieval.', 'http://localhost:8089', 'app' -) ON CONFLICT (id) DO NOTHING; \ No newline at end of file +) ON CONFLICT (id) DO NOTHING;