Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
|
|
@ -0,0 +1,73 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidLoginRequest = errors.New("invalid login request")
|
||||
ErrMissingEmail = errors.New("email is required")
|
||||
ErrInvalidEmail = errors.New("invalid email format")
|
||||
ErrMissingPassword = errors.New("password is required")
|
||||
)
|
||||
|
||||
// LoginRequestDTO represents the login request payload
|
||||
type LoginRequestDTO struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// Validate validates the login request
|
||||
// CWE-20: Improper Input Validation - Validates email format before authentication
|
||||
func (dto *LoginRequestDTO) Validate() error {
|
||||
// Validate email format
|
||||
validator := validation.NewValidator()
|
||||
if err := validator.ValidateEmail(dto.Email, "email"); err != nil {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
|
||||
// Normalize email (lowercase, trim whitespace)
|
||||
dto.Email = strings.ToLower(strings.TrimSpace(dto.Email))
|
||||
|
||||
// Validate password (non-empty)
|
||||
if strings.TrimSpace(dto.Password) == "" {
|
||||
return ErrMissingPassword
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseLoginRequest parses and validates a login request from HTTP request body
|
||||
func ParseLoginRequest(r *http.Request) (*LoginRequestDTO, error) {
|
||||
// CWE-436: Validate Content-Type before parsing
|
||||
if err := httpvalidation.RequireJSONContentType(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidLoginRequest
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse JSON
|
||||
var dto LoginRequestDTO
|
||||
if err := json.Unmarshal(body, &dto); err != nil {
|
||||
return nil, ErrInvalidLoginRequest
|
||||
}
|
||||
|
||||
// Validate
|
||||
if err := dto.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRefreshRequest = errors.New("invalid refresh token request")
|
||||
ErrMissingRefreshToken = errors.New("refresh token is required")
|
||||
)
|
||||
|
||||
// RefreshTokenRequestDTO represents the refresh token request payload
|
||||
type RefreshTokenRequestDTO struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// Validate validates the refresh token request
|
||||
// CWE-20: Improper Input Validation - Validates refresh token presence
|
||||
func (dto *RefreshTokenRequestDTO) Validate() error {
|
||||
// Validate refresh token (non-empty)
|
||||
if strings.TrimSpace(dto.RefreshToken) == "" {
|
||||
return ErrMissingRefreshToken
|
||||
}
|
||||
|
||||
// Normalize token (trim whitespace)
|
||||
dto.RefreshToken = strings.TrimSpace(dto.RefreshToken)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseRefreshTokenRequest parses and validates a refresh token request from HTTP request body
|
||||
func ParseRefreshTokenRequest(r *http.Request) (*RefreshTokenRequestDTO, error) {
|
||||
// CWE-436: Validate Content-Type before parsing
|
||||
if err := httpvalidation.RequireJSONContentType(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidRefreshRequest
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse JSON
|
||||
var dto RefreshTokenRequestDTO
|
||||
if err := json.Unmarshal(body, &dto); err != nil {
|
||||
return nil, ErrInvalidRefreshRequest
|
||||
}
|
||||
|
||||
// Validate
|
||||
if err := dto.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/dto/gateway/register_dto.go
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
|
||||
)
|
||||
|
||||
// RegisterRequest is the HTTP request for user registration
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
ConfirmPassword string `json:"confirm_password"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
Timezone string `json:"timezone,omitempty"` // Optional: defaults to "UTC" if not provided
|
||||
|
||||
// Consent fields
|
||||
AgreeTermsOfService bool `json:"agree_terms_of_service"`
|
||||
AgreePromotions bool `json:"agree_promotions"`
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `json:"agree_to_tracking_across_third_party_apps_and_services"`
|
||||
}
|
||||
|
||||
// ValidationErrors represents validation errors in RFC 9457 format
|
||||
type ValidationErrors struct {
|
||||
Errors map[string][]string
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (v *ValidationErrors) Error() string {
|
||||
if len(v.Errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
// For backward compatibility with error logging, format as string
|
||||
var messages []string
|
||||
for field, errs := range v.Errors {
|
||||
for _, err := range errs {
|
||||
messages = append(messages, fmt.Sprintf("%s: %s", field, err))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("validation errors: %v", messages)
|
||||
}
|
||||
|
||||
// Validate validates the registration request fields
|
||||
// CWE-20: Improper Input Validation - Comprehensive email validation and normalization
|
||||
// Returns all validation errors grouped together in RFC 9457 format
|
||||
func (r *RegisterRequest) Validate() error {
|
||||
v := validation.NewValidator()
|
||||
emailValidator := validation.NewEmailValidator()
|
||||
validationErrors := make(map[string][]string)
|
||||
|
||||
// Validate and normalize email
|
||||
normalizedEmail, err := emailValidator.ValidateAndNormalize(r.Email, "email")
|
||||
if err != nil {
|
||||
// Extract just the error message without the field name prefix
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["email"] = append(validationErrors["email"], errMsg)
|
||||
} else {
|
||||
r.Email = normalizedEmail
|
||||
}
|
||||
|
||||
// Validate password (non-empty, will be validated for strength in use case)
|
||||
if err := v.ValidateRequired(r.Password, "password"); err != nil {
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["password"] = append(validationErrors["password"], errMsg)
|
||||
} else if err := v.ValidateLength(r.Password, "password", 8, 128); err != nil {
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["password"] = append(validationErrors["password"], errMsg)
|
||||
}
|
||||
|
||||
// Validate confirm password
|
||||
if err := v.ValidateRequired(r.ConfirmPassword, "confirm_password"); err != nil {
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["confirm_password"] = append(validationErrors["confirm_password"], errMsg)
|
||||
} else if r.Password != r.ConfirmPassword {
|
||||
// Only check if passwords match if both are provided
|
||||
validationErrors["confirm_password"] = append(validationErrors["confirm_password"], "Passwords do not match")
|
||||
}
|
||||
|
||||
// Validate first name
|
||||
firstName, err := v.ValidateAndSanitizeString(r.FirstName, "first_name", 1, 100)
|
||||
if err != nil {
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["first_name"] = append(validationErrors["first_name"], errMsg)
|
||||
} else {
|
||||
r.FirstName = firstName
|
||||
if err := v.ValidateNoHTML(r.FirstName, "first_name"); err != nil {
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["first_name"] = append(validationErrors["first_name"], errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate last name
|
||||
lastName, err := v.ValidateAndSanitizeString(r.LastName, "last_name", 1, 100)
|
||||
if err != nil {
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["last_name"] = append(validationErrors["last_name"], errMsg)
|
||||
} else {
|
||||
r.LastName = lastName
|
||||
if err := v.ValidateNoHTML(r.LastName, "last_name"); err != nil {
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["last_name"] = append(validationErrors["last_name"], errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tenant name
|
||||
tenantName, err := v.ValidateAndSanitizeString(r.TenantName, "tenant_name", 1, 100)
|
||||
if err != nil {
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["tenant_name"] = append(validationErrors["tenant_name"], errMsg)
|
||||
} else {
|
||||
r.TenantName = tenantName
|
||||
if err := v.ValidateNoHTML(r.TenantName, "tenant_name"); err != nil {
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["tenant_name"] = append(validationErrors["tenant_name"], errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate consent: Terms of Service is REQUIRED
|
||||
if !r.AgreeTermsOfService {
|
||||
validationErrors["agree_terms_of_service"] = append(validationErrors["agree_terms_of_service"], "Must agree to terms of service")
|
||||
}
|
||||
|
||||
// Note: AgreePromotions and AgreeToTrackingAcrossThirdPartyAppsAndServices
|
||||
// are optional (defaults to false if not provided)
|
||||
|
||||
// Return all errors grouped together in RFC 9457 format
|
||||
if len(validationErrors) > 0 {
|
||||
return &ValidationErrors{Errors: validationErrors}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractErrorMessage extracts the error message after the field name prefix
|
||||
// Example: "email: invalid email format" -> "Invalid email format"
|
||||
func extractErrorMessage(fullError string) string {
|
||||
// Find the colon separator
|
||||
colonIndex := -1
|
||||
for i, char := range fullError {
|
||||
if char == ':' {
|
||||
colonIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if colonIndex == -1 {
|
||||
// No colon found, capitalize first letter and return
|
||||
if len(fullError) > 0 {
|
||||
return string(fullError[0]-32) + fullError[1:]
|
||||
}
|
||||
return fullError
|
||||
}
|
||||
|
||||
// Extract message after colon and trim spaces
|
||||
message := fullError[colonIndex+1:]
|
||||
if len(message) > 0 && message[0] == ' ' {
|
||||
message = message[1:]
|
||||
}
|
||||
|
||||
// Capitalize first letter
|
||||
if len(message) > 0 {
|
||||
firstChar := message[0]
|
||||
if firstChar >= 'a' && firstChar <= 'z' {
|
||||
message = string(firstChar-32) + message[1:]
|
||||
}
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
// RegisterResponse is the HTTP response after successful registration
|
||||
type RegisterResponse struct {
|
||||
// User details
|
||||
UserID string `json:"user_id"`
|
||||
UserEmail string `json:"user_email"`
|
||||
UserName string `json:"user_name"`
|
||||
UserRole string `json:"user_role"`
|
||||
|
||||
// Tenant details
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
|
||||
// Authentication tokens
|
||||
SessionID string `json:"session_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
AccessExpiry time.Time `json:"access_expiry"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
RefreshExpiry time.Time `json:"refresh_expiry"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package page
|
||||
|
||||
// DeleteRequest represents the delete pages request
|
||||
type DeleteRequest struct {
|
||||
PageIDs []string `json:"page_ids"`
|
||||
}
|
||||
|
||||
// DeleteResponse represents the delete pages response
|
||||
type DeleteResponse struct {
|
||||
DeletedCount int `json:"deleted_count"`
|
||||
DeindexedCount int `json:"deindexed_count"`
|
||||
FailedPages []string `json:"failed_pages,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package page
|
||||
|
||||
// SearchRequest represents the search pages request
|
||||
type SearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
Filter string `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResponse represents the search pages response
|
||||
type SearchResponse struct {
|
||||
Hits []map[string]interface{} `json:"hits"`
|
||||
Query string `json:"query"`
|
||||
ProcessingTimeMs int64 `json:"processing_time_ms"`
|
||||
TotalHits int64 `json:"total_hits"`
|
||||
Limit int64 `json:"limit"`
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package page
|
||||
|
||||
import "time"
|
||||
|
||||
// StatusResponse represents the sync status response
|
||||
type StatusResponse struct {
|
||||
SiteID string `json:"site_id"`
|
||||
TotalPages int64 `json:"total_pages"`
|
||||
PublishedPages int64 `json:"published_pages"`
|
||||
DraftPages int64 `json:"draft_pages"`
|
||||
LastSyncedAt time.Time `json:"last_synced_at"`
|
||||
PagesIndexedMonth int64 `json:"pages_indexed_month"`
|
||||
SearchRequestsMonth int64 `json:"search_requests_month"`
|
||||
LastResetAt time.Time `json:"last_reset_at"`
|
||||
SearchIndexStatus string `json:"search_index_status"`
|
||||
SearchIndexDocCount int64 `json:"search_index_doc_count"`
|
||||
}
|
||||
|
||||
// PageDetailsResponse represents the page details response
|
||||
type PageDetailsResponse struct {
|
||||
PageID string `json:"page_id"`
|
||||
Title string `json:"title"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
PostType string `json:"post_type"`
|
||||
Author string `json:"author"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
IndexedAt time.Time `json:"indexed_at"`
|
||||
MeilisearchDocID string `json:"meilisearch_doc_id"`
|
||||
IsIndexed bool `json:"is_indexed"`
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
|
||||
)
|
||||
|
||||
// Allowed page statuses
|
||||
var AllowedPageStatuses = []string{"publish", "draft", "pending", "private", "trash"}
|
||||
|
||||
// Allowed post types
|
||||
var AllowedPostTypes = []string{"post", "page", "attachment", "custom"}
|
||||
|
||||
// SyncPageInput represents a single page to sync in the request
|
||||
type SyncPageInput struct {
|
||||
PageID string `json:"page_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
PostType string `json:"post_type"`
|
||||
Author string `json:"author"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
}
|
||||
|
||||
// Validate validates a single page input
|
||||
func (p *SyncPageInput) Validate() error {
|
||||
v := validation.NewValidator()
|
||||
|
||||
// Validate page ID (required)
|
||||
if err := v.ValidateRequired(p.PageID, "page_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.ValidateLength(p.PageID, "page_id", 1, 255); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate title
|
||||
title, err := v.ValidateAndSanitizeString(p.Title, "title", 1, 500)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Title = title
|
||||
|
||||
// Validate content (optional but has max length if provided)
|
||||
if p.Content != "" {
|
||||
if err := v.ValidateLength(p.Content, "content", 0, 1000000); err != nil { // 1MB limit
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Validate excerpt (optional but has max length if provided)
|
||||
if p.Excerpt != "" {
|
||||
if err := v.ValidateLength(p.Excerpt, "excerpt", 0, 1000); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
if err := v.ValidateURL(p.URL, "url"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate status (enum)
|
||||
if err := v.ValidateEnum(p.Status, "status", AllowedPageStatuses); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate post type (enum)
|
||||
if err := v.ValidateEnum(p.PostType, "post_type", AllowedPostTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate author
|
||||
author, err := v.ValidateAndSanitizeString(p.Author, "author", 1, 255)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Author = author
|
||||
if err := v.ValidateNoHTML(p.Author, "author"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncRequest represents the sync pages request
|
||||
type SyncRequest struct {
|
||||
Pages []SyncPageInput `json:"pages"`
|
||||
}
|
||||
|
||||
// Validate validates the sync request
|
||||
func (r *SyncRequest) Validate() error {
|
||||
// Check pages array is not empty
|
||||
if len(r.Pages) == 0 {
|
||||
return fmt.Errorf("pages: array cannot be empty")
|
||||
}
|
||||
|
||||
// Validate maximum number of pages in a single request
|
||||
if len(r.Pages) > 1000 {
|
||||
return fmt.Errorf("pages: cannot sync more than 1000 pages at once")
|
||||
}
|
||||
|
||||
// Validate each page
|
||||
for i, page := range r.Pages {
|
||||
if err := page.Validate(); err != nil {
|
||||
return fmt.Errorf("pages[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncResponse represents the sync pages response
|
||||
type SyncResponse struct {
|
||||
SyncedCount int `json:"synced_count"`
|
||||
IndexedCount int `json:"indexed_count"`
|
||||
FailedPages []string `json:"failed_pages,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
|
||||
)
|
||||
|
||||
// CreateRequest represents the HTTP request for creating a site
|
||||
// Note: Domain will be extracted from SiteURL by the backend
|
||||
type CreateRequest struct {
|
||||
SiteURL string `json:"site_url"`
|
||||
}
|
||||
|
||||
// ValidationErrors represents validation errors in RFC 9457 format
|
||||
type ValidationErrors struct {
|
||||
Errors map[string][]string
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (v *ValidationErrors) Error() string {
|
||||
if len(v.Errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
// For backward compatibility with error logging, format as string
|
||||
var messages []string
|
||||
for field, errs := range v.Errors {
|
||||
for _, err := range errs {
|
||||
messages = append(messages, fmt.Sprintf("%s: %s", field, err))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("validation errors: %v", messages)
|
||||
}
|
||||
|
||||
// Validate validates the create site request fields
|
||||
// Returns all validation errors grouped together in RFC 9457 format
|
||||
func (r *CreateRequest) Validate() error {
|
||||
v := validation.NewValidator()
|
||||
validationErrors := make(map[string][]string)
|
||||
|
||||
// Validate site URL (required)
|
||||
if err := v.ValidateURL(r.SiteURL, "site_url"); err != nil {
|
||||
errMsg := extractErrorMessage(err.Error())
|
||||
validationErrors["site_url"] = append(validationErrors["site_url"], errMsg)
|
||||
}
|
||||
|
||||
// Return all errors grouped together in RFC 9457 format
|
||||
if len(validationErrors) > 0 {
|
||||
return &ValidationErrors{Errors: validationErrors}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractErrorMessage extracts the error message after the field name prefix
|
||||
// Example: "domain: invalid domain format" -> "Invalid domain format"
|
||||
func extractErrorMessage(fullError string) string {
|
||||
// Find the colon separator
|
||||
colonIndex := -1
|
||||
for i, char := range fullError {
|
||||
if char == ':' {
|
||||
colonIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if colonIndex == -1 {
|
||||
// No colon found, capitalize first letter and return
|
||||
if len(fullError) > 0 {
|
||||
return string(fullError[0]-32) + fullError[1:]
|
||||
}
|
||||
return fullError
|
||||
}
|
||||
|
||||
// Extract message after colon and trim spaces
|
||||
message := fullError[colonIndex+1:]
|
||||
if len(message) > 0 && message[0] == ' ' {
|
||||
message = message[1:]
|
||||
}
|
||||
|
||||
// Capitalize first letter
|
||||
if len(message) > 0 {
|
||||
firstChar := message[0]
|
||||
if firstChar >= 'a' && firstChar <= 'z' {
|
||||
message = string(firstChar-32) + message[1:]
|
||||
}
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
// CreateResponse represents the HTTP response after creating a site
|
||||
type CreateResponse struct {
|
||||
ID string `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
SiteURL string `json:"site_url"`
|
||||
APIKey string `json:"api_key"` // Only returned once at creation
|
||||
Status string `json:"status"`
|
||||
VerificationToken string `json:"verification_token"`
|
||||
SearchIndexName string `json:"search_index_name"`
|
||||
VerificationInstructions string `json:"verification_instructions"` // DNS TXT record setup instructions
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package site
|
||||
|
||||
import "time"
|
||||
|
||||
// GetResponse represents the HTTP response for getting a site
|
||||
type GetResponse struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Domain string `json:"domain"`
|
||||
SiteURL string `json:"site_url"`
|
||||
APIKeyPrefix string `json:"api_key_prefix"`
|
||||
APIKeyLastFour string `json:"api_key_last_four"`
|
||||
Status string `json:"status"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
SearchIndexName string `json:"search_index_name"`
|
||||
TotalPagesIndexed int64 `json:"total_pages_indexed"`
|
||||
LastIndexedAt time.Time `json:"last_indexed_at,omitempty"`
|
||||
PluginVersion string `json:"plugin_version,omitempty"`
|
||||
StorageUsedBytes int64 `json:"storage_used_bytes"`
|
||||
SearchRequestsCount int64 `json:"search_requests_count"`
|
||||
MonthlyPagesIndexed int64 `json:"monthly_pages_indexed"`
|
||||
LastResetAt time.Time `json:"last_reset_at"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package site
|
||||
|
||||
import "time"
|
||||
|
||||
// ListResponse represents the HTTP response for listing sites
|
||||
type ListResponse struct {
|
||||
Sites []SiteListItem `json:"sites"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// SiteListItem represents a site in the list
|
||||
type SiteListItem struct {
|
||||
ID string `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
Status string `json:"status"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
TotalPagesIndexed int64 `json:"total_pages_indexed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package site
|
||||
|
||||
import "time"
|
||||
|
||||
// RotateAPIKeyResponse represents the HTTP response after rotating an API key
|
||||
type RotateAPIKeyResponse struct {
|
||||
NewAPIKey string `json:"new_api_key"` // New API key (only returned once)
|
||||
OldKeyLastFour string `json:"old_key_last_four"`
|
||||
RotatedAt time.Time `json:"rotated_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
|
||||
)
|
||||
|
||||
// CreateRequest represents the HTTP request for creating a tenant
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
// Validate validates the create tenant request
|
||||
// CWE-20: Improper Input Validation
|
||||
func (r *CreateRequest) Validate() error {
|
||||
validator := validation.NewValidator()
|
||||
|
||||
// Validate name: 3-100 chars, printable, no HTML
|
||||
if err := validator.ValidateRequired(r.Name, "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validator.ValidateLength(r.Name, "name", 3, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validator.ValidatePrintable(r.Name, "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validator.ValidateNoHTML(r.Name, "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate slug: uses existing slug validation (lowercase, hyphens, 3-63 chars)
|
||||
if err := validator.ValidateSlug(r.Slug, "slug"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sanitize inputs
|
||||
r.Name = validator.SanitizeString(r.Name)
|
||||
r.Slug = validator.SanitizeString(r.Slug)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateResponse represents the HTTP response after creating a tenant
|
||||
type CreateResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package tenant
|
||||
|
||||
import "time"
|
||||
|
||||
// GetResponse represents the HTTP response when retrieving a tenant
|
||||
type GetResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package user
|
||||
|
||||
import "time"
|
||||
|
||||
// CreateRequest is the HTTP request for creating a user
|
||||
type CreateRequest struct {
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
|
||||
// CreateResponse is the HTTP response after creating a user
|
||||
type CreateResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package user
|
||||
|
||||
import "time"
|
||||
|
||||
// GetResponse is the HTTP response for getting a user
|
||||
type GetResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/admin/account_status_handler.go
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/ratelimit"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
|
||||
)
|
||||
|
||||
// AccountStatusHandler handles HTTP requests for checking account lock status
|
||||
type AccountStatusHandler struct {
|
||||
loginRateLimiter ratelimit.LoginRateLimiter
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAccountStatusHandler creates a new account status handler
|
||||
func NewAccountStatusHandler(
|
||||
loginRateLimiter ratelimit.LoginRateLimiter,
|
||||
logger *zap.Logger,
|
||||
) *AccountStatusHandler {
|
||||
return &AccountStatusHandler{
|
||||
loginRateLimiter: loginRateLimiter,
|
||||
logger: logger.Named("account-status-handler"),
|
||||
}
|
||||
}
|
||||
|
||||
// ProvideAccountStatusHandler creates a new AccountStatusHandler for dependency injection
|
||||
func ProvideAccountStatusHandler(
|
||||
loginRateLimiter ratelimit.LoginRateLimiter,
|
||||
logger *zap.Logger,
|
||||
) *AccountStatusHandler {
|
||||
return NewAccountStatusHandler(loginRateLimiter, logger)
|
||||
}
|
||||
|
||||
// AccountStatusResponse represents the account status response
|
||||
type AccountStatusResponse struct {
|
||||
Email string `json:"email"`
|
||||
IsLocked bool `json:"is_locked"`
|
||||
FailedAttempts int `json:"failed_attempts"`
|
||||
RemainingTime string `json:"remaining_time,omitempty"`
|
||||
RemainingSeconds int `json:"remaining_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// Handle processes GET /api/v1/admin/account-status?email=user@example.com requests
|
||||
// This endpoint allows administrators to check if an account is locked and get details
|
||||
func (h *AccountStatusHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("handling account status request")
|
||||
|
||||
// CWE-20: Validate email query parameter
|
||||
email, err := validation.ValidateQueryEmail(r, "email")
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid email query parameter", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
locked, remainingTime, err := h.loginRateLimiter.IsAccountLocked(r.Context(), email)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to check account lock status",
|
||||
logger.EmailHash(email),
|
||||
zap.Error(err))
|
||||
httperror.ProblemInternalServerError(w, "Failed to check account status")
|
||||
return
|
||||
}
|
||||
|
||||
// Get failed attempts count
|
||||
failedAttempts, err := h.loginRateLimiter.GetFailedAttempts(r.Context(), email)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get failed attempts",
|
||||
logger.EmailHash(email),
|
||||
zap.Error(err))
|
||||
// Continue with locked status even if we can't get attempt count
|
||||
failedAttempts = 0
|
||||
}
|
||||
|
||||
response := &AccountStatusResponse{
|
||||
Email: email,
|
||||
IsLocked: locked,
|
||||
FailedAttempts: failedAttempts,
|
||||
}
|
||||
|
||||
if locked {
|
||||
response.RemainingTime = formatDuration(remainingTime)
|
||||
response.RemainingSeconds = int(remainingTime.Seconds())
|
||||
}
|
||||
|
||||
h.logger.Info("account status checked",
|
||||
logger.EmailHash(email),
|
||||
zap.Bool("is_locked", locked),
|
||||
zap.Int("failed_attempts", failedAttempts))
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
||||
// formatDuration formats a duration into a human-readable string
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
return "0s"
|
||||
}
|
||||
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
seconds := int(d.Seconds()) % 60
|
||||
|
||||
if hours > 0 {
|
||||
return formatWithUnit(hours, "hour") + " " + formatWithUnit(minutes, "minute")
|
||||
}
|
||||
if minutes > 0 {
|
||||
return formatWithUnit(minutes, "minute") + " " + formatWithUnit(seconds, "second")
|
||||
}
|
||||
return formatWithUnit(seconds, "second")
|
||||
}
|
||||
|
||||
func formatWithUnit(value int, unit string) string {
|
||||
if value == 0 {
|
||||
return ""
|
||||
}
|
||||
if value == 1 {
|
||||
return "1 " + unit
|
||||
}
|
||||
return string(rune(value)) + " " + unit + "s"
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/admin/unlock_account_handler.go
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
securityeventservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/securityevent"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/ratelimit"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
|
||||
)
|
||||
|
||||
// UnlockAccountHandler handles HTTP requests for unlocking locked accounts
|
||||
type UnlockAccountHandler struct {
|
||||
loginRateLimiter ratelimit.LoginRateLimiter
|
||||
securityEventLogger securityeventservice.Logger
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUnlockAccountHandler creates a new unlock account handler
|
||||
func NewUnlockAccountHandler(
|
||||
loginRateLimiter ratelimit.LoginRateLimiter,
|
||||
securityEventLogger securityeventservice.Logger,
|
||||
logger *zap.Logger,
|
||||
) *UnlockAccountHandler {
|
||||
return &UnlockAccountHandler{
|
||||
loginRateLimiter: loginRateLimiter,
|
||||
securityEventLogger: securityEventLogger,
|
||||
logger: logger.Named("unlock-account-handler"),
|
||||
}
|
||||
}
|
||||
|
||||
// ProvideUnlockAccountHandler creates a new UnlockAccountHandler for dependency injection
|
||||
func ProvideUnlockAccountHandler(
|
||||
loginRateLimiter ratelimit.LoginRateLimiter,
|
||||
securityEventLogger securityeventservice.Logger,
|
||||
logger *zap.Logger,
|
||||
) *UnlockAccountHandler {
|
||||
return NewUnlockAccountHandler(loginRateLimiter, securityEventLogger, logger)
|
||||
}
|
||||
|
||||
// UnlockAccountRequest represents the unlock account request payload
|
||||
type UnlockAccountRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// UnlockAccountResponse represents the unlock account response
|
||||
type UnlockAccountResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// Handle processes POST /api/v1/admin/unlock-account requests
|
||||
// This endpoint allows administrators to manually unlock accounts that have been
|
||||
// locked due to excessive failed login attempts
|
||||
func (h *UnlockAccountHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("handling unlock account request")
|
||||
|
||||
// Parse request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
h.logger.Warn("failed to read request body", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid request body")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req UnlockAccountRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
h.logger.Warn("failed to parse request body", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-20: Comprehensive email validation
|
||||
emailValidator := validation.NewEmailValidator()
|
||||
normalizedEmail, err := emailValidator.ValidateAndNormalize(req.Email, "email")
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid email", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
req.Email = normalizedEmail
|
||||
|
||||
// Check if account is currently locked
|
||||
locked, remainingTime, err := h.loginRateLimiter.IsAccountLocked(r.Context(), req.Email)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to check account lock status",
|
||||
logger.EmailHash(req.Email),
|
||||
zap.Error(err))
|
||||
httperror.ProblemInternalServerError(w, "Failed to check account status")
|
||||
return
|
||||
}
|
||||
|
||||
if !locked {
|
||||
h.logger.Info("account not locked - nothing to do",
|
||||
logger.EmailHash(req.Email))
|
||||
httpresponse.OK(w, &UnlockAccountResponse{
|
||||
Success: true,
|
||||
Message: "Account is not locked",
|
||||
Email: req.Email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Unlock the account
|
||||
if err := h.loginRateLimiter.UnlockAccount(r.Context(), req.Email); err != nil {
|
||||
h.logger.Error("failed to unlock account",
|
||||
logger.EmailHash(req.Email),
|
||||
zap.Error(err))
|
||||
httperror.ProblemInternalServerError(w, "Failed to unlock account")
|
||||
return
|
||||
}
|
||||
|
||||
// Get admin user ID from context (set by JWT middleware)
|
||||
// TODO: Extract admin user ID from JWT claims when authentication is added
|
||||
adminUserID := "admin" // Placeholder until JWT middleware is integrated
|
||||
|
||||
// Log security event
|
||||
redactor := logger.NewSensitiveFieldRedactor()
|
||||
if err := h.securityEventLogger.LogAccountUnlocked(
|
||||
r.Context(),
|
||||
redactor.HashForLogging(req.Email),
|
||||
adminUserID,
|
||||
); err != nil {
|
||||
h.logger.Error("failed to log security event",
|
||||
logger.EmailHash(req.Email),
|
||||
zap.Error(err))
|
||||
// Don't fail the request if logging fails
|
||||
}
|
||||
|
||||
h.logger.Info("account unlocked successfully",
|
||||
logger.EmailHash(req.Email),
|
||||
logger.SafeEmail("email_redacted", req.Email),
|
||||
zap.Duration("was_locked_for", remainingTime))
|
||||
|
||||
httpresponse.OK(w, &UnlockAccountResponse{
|
||||
Success: true,
|
||||
Message: "Account unlocked successfully",
|
||||
Email: req.Email,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway/hello_handler.go
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
|
||||
)
|
||||
|
||||
// HelloHandler handles the hello endpoint for authenticated users
|
||||
type HelloHandler struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideHelloHandler creates a new HelloHandler
|
||||
func ProvideHelloHandler(logger *zap.Logger) *HelloHandler {
|
||||
return &HelloHandler{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HelloRequest represents the request body for the hello endpoint
|
||||
type HelloRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// HelloResponse represents the response for the hello endpoint
|
||||
type HelloResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for the hello endpoint
|
||||
// Security: CWE-20, CWE-79, CWE-117 - Comprehensive input validation and sanitization
|
||||
func (h *HelloHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// M-2: Enforce strict Content-Type validation
|
||||
// CWE-436: Validate Content-Type before parsing
|
||||
if err := httpvalidation.ValidateJSONContentTypeStrict(r); err != nil {
|
||||
h.logger.Warn("invalid content type", zap.String("content_type", r.Header.Get("Content-Type")))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req HelloRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Warn("invalid request body", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// H-1: Comprehensive input validation
|
||||
// CWE-20: Improper Input Validation
|
||||
validator := validation.NewValidator()
|
||||
|
||||
// Validate required
|
||||
if err := validator.ValidateRequired(req.Name, "name"); err != nil {
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Validate length (1-100 characters is reasonable for a name)
|
||||
if err := validator.ValidateLength(req.Name, "name", 1, 100); err != nil {
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Validate printable characters only
|
||||
if err := validator.ValidatePrintable(req.Name, "name"); err != nil {
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// M-1: Validate no HTML tags (XSS prevention)
|
||||
// CWE-79: Cross-site Scripting
|
||||
if err := validator.ValidateNoHTML(req.Name, "name"); err != nil {
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize input
|
||||
req.Name = validator.SanitizeString(req.Name)
|
||||
|
||||
// H-2: Fix log injection vulnerability
|
||||
// CWE-117: Improper Output Neutralization for Logs
|
||||
// Hash the name to prevent log injection and protect PII
|
||||
nameHash := logger.HashString(req.Name)
|
||||
|
||||
// L-1: Extract user ID from context for correlation
|
||||
// Get authenticated user info from JWT context
|
||||
userID := "unknown"
|
||||
if uid := r.Context().Value(constants.SessionUserID); uid != nil {
|
||||
if userIDUint, ok := uid.(uint64); ok {
|
||||
userID = fmt.Sprintf("%d", userIDUint)
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("hello endpoint accessed",
|
||||
zap.String("user_id", userID),
|
||||
zap.String("name_hash", nameHash))
|
||||
|
||||
// M-1: HTML-escape the name to prevent XSS in any context
|
||||
// CWE-79: Cross-site Scripting
|
||||
safeName := html.EscapeString(req.Name)
|
||||
|
||||
// Create response with sanitized output
|
||||
response := HelloResponse{
|
||||
Message: fmt.Sprintf("Hello, %s! Welcome to MaplePress Backend.", safeName),
|
||||
}
|
||||
|
||||
// Write response
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway/login_handler.go
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
gatewaydto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/gateway"
|
||||
gatewaysvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/gateway"
|
||||
securityeventservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/securityevent"
|
||||
gatewayuc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/gateway"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/ratelimit"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/clientip"
|
||||
)
|
||||
|
||||
// LoginHandler handles HTTP requests for user login
|
||||
type LoginHandler struct {
|
||||
loginService gatewaysvc.LoginService
|
||||
loginRateLimiter ratelimit.LoginRateLimiter
|
||||
securityEventLogger securityeventservice.Logger
|
||||
ipExtractor *clientip.Extractor
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLoginHandler creates a new login handler
|
||||
// CWE-307: Integrates rate limiting and account lockout protection
|
||||
// CWE-778: Integrates security event logging for audit trails
|
||||
func NewLoginHandler(
|
||||
loginService gatewaysvc.LoginService,
|
||||
loginRateLimiter ratelimit.LoginRateLimiter,
|
||||
securityEventLogger securityeventservice.Logger,
|
||||
ipExtractor *clientip.Extractor,
|
||||
logger *zap.Logger,
|
||||
) *LoginHandler {
|
||||
return &LoginHandler{
|
||||
loginService: loginService,
|
||||
loginRateLimiter: loginRateLimiter,
|
||||
securityEventLogger: securityEventLogger,
|
||||
ipExtractor: ipExtractor,
|
||||
logger: logger.Named("login-handler"),
|
||||
}
|
||||
}
|
||||
|
||||
// ProvideLoginHandler creates a new LoginHandler for dependency injection
|
||||
func ProvideLoginHandler(
|
||||
loginService gatewaysvc.LoginService,
|
||||
loginRateLimiter ratelimit.LoginRateLimiter,
|
||||
securityEventLogger securityeventservice.Logger,
|
||||
ipExtractor *clientip.Extractor,
|
||||
logger *zap.Logger,
|
||||
) *LoginHandler {
|
||||
return NewLoginHandler(loginService, loginRateLimiter, securityEventLogger, ipExtractor, logger)
|
||||
}
|
||||
|
||||
// Handle processes POST /api/v1/login requests
|
||||
// CWE-307: Implements rate limiting and account lockout protection against brute force attacks
|
||||
func (h *LoginHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("handling login request")
|
||||
|
||||
// Parse and validate request
|
||||
dto, err := gatewaydto.ParseLoginRequest(r)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid login request", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-348: Extract client IP securely with trusted proxy validation
|
||||
clientIP := h.ipExtractor.Extract(r)
|
||||
|
||||
// CWE-307: Check rate limits and account lockout BEFORE attempting authentication
|
||||
allowed, isLocked, remainingAttempts, err := h.loginRateLimiter.CheckAndRecordAttempt(
|
||||
r.Context(),
|
||||
dto.Email,
|
||||
clientIP,
|
||||
)
|
||||
if err != nil {
|
||||
// Log error but continue (fail open)
|
||||
h.logger.Error("rate limiter error",
|
||||
logger.EmailHash(dto.Email),
|
||||
zap.String("ip", clientIP),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// Account is locked - return error immediately
|
||||
if isLocked {
|
||||
h.logger.Warn("login attempt on locked account",
|
||||
logger.EmailHash(dto.Email),
|
||||
logger.SafeEmail("email_redacted", dto.Email),
|
||||
zap.String("ip", clientIP))
|
||||
|
||||
// Add Retry-After header (30 minutes)
|
||||
w.Header().Set("Retry-After", "1800")
|
||||
|
||||
httperror.ProblemTooManyRequests(w, "Account temporarily locked due to too many failed login attempts. Please try again later.")
|
||||
return
|
||||
}
|
||||
|
||||
// IP rate limit exceeded - return error immediately
|
||||
if !allowed {
|
||||
h.logger.Warn("login rate limit exceeded",
|
||||
logger.EmailHash(dto.Email),
|
||||
zap.String("ip", clientIP))
|
||||
|
||||
// CWE-778: Log security event for IP rate limit
|
||||
h.securityEventLogger.LogIPRateLimitExceeded(r.Context(), clientIP)
|
||||
|
||||
// Add Retry-After header (15 minutes)
|
||||
w.Header().Set("Retry-After", "900")
|
||||
|
||||
httperror.ProblemTooManyRequests(w, "Too many login attempts from this IP address. Please try again later.")
|
||||
return
|
||||
}
|
||||
|
||||
// Execute login
|
||||
response, err := h.loginService.Login(r.Context(), &gatewaysvc.LoginInput{
|
||||
Email: dto.Email,
|
||||
Password: dto.Password,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gatewayuc.ErrInvalidCredentials) {
|
||||
// CWE-307: Record failed login attempt for account lockout tracking
|
||||
if err := h.loginRateLimiter.RecordFailedAttempt(r.Context(), dto.Email, clientIP); err != nil {
|
||||
h.logger.Error("failed to record failed login attempt",
|
||||
logger.EmailHash(dto.Email),
|
||||
zap.String("ip", clientIP),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// CWE-532: Log with redacted email (security event logging)
|
||||
h.logger.Warn("login failed: invalid credentials",
|
||||
logger.EmailHash(dto.Email),
|
||||
logger.SafeEmail("email_redacted", dto.Email),
|
||||
zap.String("ip", clientIP),
|
||||
zap.Int("remaining_attempts", remainingAttempts-1))
|
||||
|
||||
// CWE-778: Log security event for failed login
|
||||
redactor := logger.NewSensitiveFieldRedactor()
|
||||
h.securityEventLogger.LogFailedLogin(r.Context(), redactor.HashForLogging(dto.Email), clientIP, remainingAttempts-1)
|
||||
|
||||
// Include remaining attempts in error message to help legitimate users
|
||||
errorMsg := "Invalid email or password."
|
||||
if remainingAttempts <= 3 {
|
||||
errorMsg = fmt.Sprintf("Invalid email or password. %d attempts remaining before account lockout.", remainingAttempts-1)
|
||||
}
|
||||
|
||||
httperror.ProblemUnauthorized(w, errorMsg)
|
||||
return
|
||||
}
|
||||
h.logger.Error("login failed", zap.Error(err))
|
||||
httperror.ProblemInternalServerError(w, "Failed to process login. Please try again later.")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-307: Record successful login (resets failed attempt counters)
|
||||
if err := h.loginRateLimiter.RecordSuccessfulLogin(r.Context(), dto.Email, clientIP); err != nil {
|
||||
// Log error but don't fail the login
|
||||
h.logger.Error("failed to reset login counters after successful login",
|
||||
logger.EmailHash(dto.Email),
|
||||
zap.String("ip", clientIP),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// CWE-532: Log with safe identifiers only (no PII)
|
||||
h.logger.Info("login successful",
|
||||
zap.String("user_id", response.UserID),
|
||||
zap.String("tenant_id", response.TenantID),
|
||||
logger.EmailHash(response.UserEmail),
|
||||
zap.String("ip", clientIP))
|
||||
|
||||
// CWE-778: Log security event for successful login
|
||||
redactor := logger.NewSensitiveFieldRedactor()
|
||||
h.securityEventLogger.LogSuccessfulLogin(r.Context(), redactor.HashForLogging(dto.Email), clientIP)
|
||||
|
||||
// Return response with pretty JSON
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway/me_handler.go
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
)
|
||||
|
||||
// MeHandler handles the /me endpoint for getting authenticated user profile
|
||||
type MeHandler struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideMeHandler creates a new MeHandler
|
||||
func ProvideMeHandler(logger *zap.Logger) *MeHandler {
|
||||
return &MeHandler{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// MeResponse represents the user profile response
|
||||
type MeResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for the /me endpoint
|
||||
func (h *MeHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract user info from context (set by JWT middleware)
|
||||
userUUID, ok := r.Context().Value(constants.SessionUserUUID).(string)
|
||||
if !ok || userUUID == "" {
|
||||
h.logger.Error("user UUID not found in context")
|
||||
httperror.ProblemUnauthorized(w, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
userEmail, _ := r.Context().Value(constants.SessionUserEmail).(string)
|
||||
userName, _ := r.Context().Value(constants.SessionUserName).(string)
|
||||
userRole, _ := r.Context().Value(constants.SessionUserRole).(string)
|
||||
tenantUUID, _ := r.Context().Value(constants.SessionTenantID).(string)
|
||||
|
||||
// CWE-532: Use redacted email for logging
|
||||
h.logger.Info("/me endpoint accessed",
|
||||
zap.String("user_id", userUUID),
|
||||
logger.EmailHash(userEmail),
|
||||
logger.SafeEmail("email_redacted", userEmail))
|
||||
|
||||
// Create response
|
||||
response := MeResponse{
|
||||
UserID: userUUID,
|
||||
Email: userEmail,
|
||||
Name: userName,
|
||||
Role: userRole,
|
||||
TenantID: tenantUUID,
|
||||
}
|
||||
|
||||
// Write response with pretty JSON
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway/refresh_handler.go
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
gatewaydto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/gateway"
|
||||
gatewaysvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/gateway"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// RefreshTokenHandler handles HTTP requests for token refresh
|
||||
type RefreshTokenHandler struct {
|
||||
refreshTokenService gatewaysvc.RefreshTokenService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewRefreshTokenHandler creates a new refresh token handler
|
||||
func NewRefreshTokenHandler(
|
||||
refreshTokenService gatewaysvc.RefreshTokenService,
|
||||
logger *zap.Logger,
|
||||
) *RefreshTokenHandler {
|
||||
return &RefreshTokenHandler{
|
||||
refreshTokenService: refreshTokenService,
|
||||
logger: logger.Named("refresh-token-handler"),
|
||||
}
|
||||
}
|
||||
|
||||
// ProvideRefreshTokenHandler creates a new RefreshTokenHandler for dependency injection
|
||||
func ProvideRefreshTokenHandler(
|
||||
refreshTokenService gatewaysvc.RefreshTokenService,
|
||||
logger *zap.Logger,
|
||||
) *RefreshTokenHandler {
|
||||
return NewRefreshTokenHandler(refreshTokenService, logger)
|
||||
}
|
||||
|
||||
// Handle processes POST /api/v1/refresh requests
|
||||
// CWE-613: Validates session still exists before issuing new tokens
|
||||
func (h *RefreshTokenHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("handling token refresh request")
|
||||
|
||||
// Parse and validate request
|
||||
dto, err := gatewaydto.ParseRefreshTokenRequest(r)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid refresh token request", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Execute token refresh
|
||||
response, err := h.refreshTokenService.RefreshToken(r.Context(), &gatewaysvc.RefreshTokenInput{
|
||||
RefreshToken: dto.RefreshToken,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Warn("token refresh failed", zap.Error(err))
|
||||
|
||||
// Return appropriate error based on error message
|
||||
switch err.Error() {
|
||||
case "invalid or expired refresh token":
|
||||
httperror.ProblemUnauthorized(w, "Invalid or expired refresh token. Please log in again.")
|
||||
case "session not found or expired":
|
||||
httperror.ProblemUnauthorized(w, "Session has expired or been invalidated. Please log in again.")
|
||||
default:
|
||||
httperror.ProblemInternalServerError(w, "Failed to refresh token. Please try again later.")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-532: Log with safe identifiers only (no PII)
|
||||
h.logger.Info("token refresh successful",
|
||||
zap.String("user_id", response.UserID),
|
||||
zap.String("tenant_id", response.TenantID),
|
||||
zap.String("session_id", response.SessionID))
|
||||
|
||||
// Return response with pretty JSON
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway/register_handler.go
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
gatewaydto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/gateway"
|
||||
gatewaysvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/gateway"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/clientip"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
|
||||
)
|
||||
|
||||
// RegisterHandler handles user registration HTTP requests
|
||||
type RegisterHandler struct {
|
||||
service gatewaysvc.RegisterService
|
||||
ipExtractor *clientip.Extractor
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideRegisterHandler creates a new RegisterHandler
|
||||
func ProvideRegisterHandler(
|
||||
service gatewaysvc.RegisterService,
|
||||
ipExtractor *clientip.Extractor,
|
||||
logger *zap.Logger,
|
||||
) *RegisterHandler {
|
||||
return &RegisterHandler{
|
||||
service: service,
|
||||
ipExtractor: ipExtractor,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for user registration
|
||||
func (h *RegisterHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// CWE-436: Validate Content-Type before parsing to prevent interpretation conflicts
|
||||
if err := httpvalidation.RequireJSONContentType(r); err != nil {
|
||||
h.logger.Warn("invalid content type",
|
||||
zap.String("content_type", r.Header.Get("Content-Type")))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req gatewaydto.RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Warn("invalid request body", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid request body format. Please check your JSON syntax.")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-20: Comprehensive input validation
|
||||
if err := req.Validate(); err != nil {
|
||||
h.logger.Warn("registration request validation failed", zap.Error(err))
|
||||
|
||||
// Check if it's a structured validation error (RFC 9457 format)
|
||||
if validationErr, ok := err.(*gatewaydto.ValidationErrors); ok {
|
||||
httperror.ValidationError(w, validationErr.Errors, "One or more validation errors occurred")
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for non-structured errors
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-348: Extract IP address securely with X-Forwarded-For validation
|
||||
// Only trusts X-Forwarded-For if request comes from configured trusted proxies
|
||||
ipAddress := h.ipExtractor.Extract(r)
|
||||
|
||||
// Default timezone to UTC if not provided
|
||||
timezone := req.Timezone
|
||||
if timezone == "" {
|
||||
timezone = "UTC"
|
||||
h.logger.Debug("timezone not provided, defaulting to UTC")
|
||||
}
|
||||
|
||||
// Generate tenant slug from tenant name
|
||||
validator := validation.NewValidator()
|
||||
tenantSlug := validator.GenerateSlug(req.TenantName)
|
||||
h.logger.Debug("generated tenant slug from name",
|
||||
zap.String("tenant_name", req.TenantName),
|
||||
zap.String("tenant_slug", tenantSlug))
|
||||
|
||||
// Map DTO to service input
|
||||
input := &gatewaysvc.RegisterInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
TenantName: req.TenantName,
|
||||
TenantSlug: tenantSlug,
|
||||
Timezone: timezone,
|
||||
|
||||
// Consent fields
|
||||
AgreeTermsOfService: req.AgreeTermsOfService,
|
||||
AgreePromotions: req.AgreePromotions,
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices: req.AgreeToTrackingAcrossThirdPartyAppsAndServices,
|
||||
|
||||
// IP address for audit trail
|
||||
CreatedFromIPAddress: ipAddress,
|
||||
}
|
||||
|
||||
// Call service
|
||||
output, err := h.service.Register(r.Context(), input)
|
||||
if err != nil {
|
||||
// CWE-532: Log with redacted sensitive information
|
||||
h.logger.Error("failed to register user",
|
||||
zap.Error(err),
|
||||
logger.EmailHash(req.Email),
|
||||
logger.SafeEmail("email_redacted", req.Email),
|
||||
logger.TenantSlugHash(tenantSlug),
|
||||
logger.SafeTenantSlug("tenant_slug_redacted", tenantSlug))
|
||||
|
||||
// Check for specific errors
|
||||
errMsg := err.Error()
|
||||
switch {
|
||||
case errMsg == "user already exists":
|
||||
// CWE-203: Return generic message to prevent user enumeration
|
||||
httperror.ProblemConflict(w, "Registration failed. The provided information is already in use.")
|
||||
case errMsg == "tenant already exists":
|
||||
// CWE-203: Return generic message to prevent tenant slug enumeration
|
||||
// Prevents attackers from discovering valid tenant slugs for reconnaissance
|
||||
httperror.ProblemConflict(w, "Registration failed. The provided information is already in use.")
|
||||
case errMsg == "must agree to terms of service":
|
||||
httperror.ProblemBadRequest(w, "You must agree to the terms of service to create an account.")
|
||||
case errMsg == "password must be at least 8 characters":
|
||||
httperror.ProblemBadRequest(w, "Password must be at least 8 characters long.")
|
||||
// CWE-521: Password breach checking
|
||||
case strings.Contains(errMsg, "data breaches"):
|
||||
httperror.ProblemBadRequest(w, "This password has been found in data breaches and cannot be used. Please choose a different password.")
|
||||
// CWE-521: Granular password strength errors for better user experience
|
||||
case errMsg == "password must contain at least one uppercase letter (A-Z)":
|
||||
httperror.ProblemBadRequest(w, "Password must contain at least one uppercase letter (A-Z).")
|
||||
case errMsg == "password must contain at least one lowercase letter (a-z)":
|
||||
httperror.ProblemBadRequest(w, "Password must contain at least one lowercase letter (a-z).")
|
||||
case errMsg == "password must contain at least one number (0-9)":
|
||||
httperror.ProblemBadRequest(w, "Password must contain at least one number (0-9).")
|
||||
case errMsg == "password must contain at least one special character (!@#$%^&*()_+-=[]{}; etc.)":
|
||||
httperror.ProblemBadRequest(w, "Password must contain at least one special character (!@#$%^&*()_+-=[]{}; etc.).")
|
||||
case errMsg == "password must contain uppercase, lowercase, number, and special character":
|
||||
httperror.ProblemBadRequest(w, "Password must contain uppercase, lowercase, number, and special character.")
|
||||
case errMsg == "invalid email format":
|
||||
httperror.ProblemBadRequest(w, "Invalid email format. Please provide a valid email address.")
|
||||
case errMsg == "tenant slug must contain only lowercase letters, numbers, and hyphens":
|
||||
httperror.ProblemBadRequest(w, "Tenant name must contain only lowercase letters, numbers, and hyphens.")
|
||||
default:
|
||||
httperror.ProblemInternalServerError(w, "Failed to register user. Please try again later.")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-532: Log with safe identifiers (no PII)
|
||||
h.logger.Info("user registered successfully",
|
||||
zap.String("user_id", output.UserID),
|
||||
zap.String("tenant_id", output.TenantID),
|
||||
logger.EmailHash(output.UserEmail))
|
||||
|
||||
// Map to response DTO
|
||||
response := gatewaydto.RegisterResponse{
|
||||
UserID: output.UserID,
|
||||
UserEmail: output.UserEmail,
|
||||
UserName: output.UserName,
|
||||
UserRole: output.UserRole,
|
||||
TenantID: output.TenantID,
|
||||
TenantName: output.TenantName,
|
||||
TenantSlug: output.TenantSlug,
|
||||
SessionID: output.SessionID,
|
||||
AccessToken: output.AccessToken,
|
||||
AccessExpiry: output.AccessExpiry,
|
||||
RefreshToken: output.RefreshToken,
|
||||
RefreshExpiry: output.RefreshExpiry,
|
||||
CreatedAt: output.CreatedAt,
|
||||
}
|
||||
|
||||
// Write response
|
||||
httpresponse.Created(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package healthcheck
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// Handler handles healthcheck requests
|
||||
type Handler struct{}
|
||||
|
||||
// ProvideHealthCheckHandler creates a new health check handler
|
||||
func ProvideHealthCheckHandler() *Handler {
|
||||
return &Handler{}
|
||||
}
|
||||
|
||||
// Handle handles the healthcheck request
|
||||
func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
response := map[string]string{
|
||||
"status": "healthy",
|
||||
}
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
pagedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/page"
|
||||
pageservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/page"
|
||||
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
|
||||
)
|
||||
|
||||
// DeletePagesHandler handles page deletion from WordPress plugin
|
||||
type DeletePagesHandler struct {
|
||||
deleteService pageservice.DeletePagesService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideDeletePagesHandler creates a new DeletePagesHandler
|
||||
func ProvideDeletePagesHandler(
|
||||
deleteService pageservice.DeletePagesService,
|
||||
logger *zap.Logger,
|
||||
) *DeletePagesHandler {
|
||||
return &DeletePagesHandler{
|
||||
deleteService: deleteService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for deleting pages
|
||||
// This endpoint is protected by API key middleware
|
||||
func (h *DeletePagesHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get site information from context (populated by API key middleware)
|
||||
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
|
||||
if !ok || !isAuthenticated {
|
||||
h.logger.Error("site not authenticated in context")
|
||||
httperror.ProblemUnauthorized(w, "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract site ID and tenant ID from context
|
||||
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
|
||||
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
|
||||
|
||||
h.logger.Info("delete pages request",
|
||||
zap.String("tenant_id", tenantIDStr),
|
||||
zap.String("site_id", siteIDStr))
|
||||
|
||||
// Parse IDs
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid tenant ID")
|
||||
return
|
||||
}
|
||||
|
||||
siteID, err := gocql.ParseUUID(siteIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid site ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid site ID")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-436: Validate Content-Type before parsing
|
||||
if err := httpvalidation.ValidateJSONContentType(r); err != nil {
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req pagedto.DeleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.ProblemBadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if len(req.PageIDs) == 0 {
|
||||
httperror.ProblemBadRequest(w, "page_ids array is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert DTO to use case input
|
||||
input := &pageusecase.DeletePagesInput{
|
||||
PageIDs: req.PageIDs,
|
||||
}
|
||||
|
||||
// Call service
|
||||
output, err := h.deleteService.DeletePages(r.Context(), tenantID, siteID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to delete pages",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteIDStr))
|
||||
|
||||
// Check for specific errors
|
||||
if err.Error() == "site not found" {
|
||||
httperror.ProblemNotFound(w, "site not found")
|
||||
return
|
||||
}
|
||||
if err.Error() == "site is not verified" {
|
||||
httperror.ProblemForbidden(w, "site is not verified")
|
||||
return
|
||||
}
|
||||
|
||||
httperror.ProblemInternalServerError(w, "failed to delete pages")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := pagedto.DeleteResponse{
|
||||
DeletedCount: output.DeletedCount,
|
||||
DeindexedCount: output.DeindexedCount,
|
||||
FailedPages: output.FailedPages,
|
||||
Message: output.Message,
|
||||
}
|
||||
|
||||
h.logger.Info("pages deleted successfully",
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.Int("deleted_count", output.DeletedCount))
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
||||
// HandleDeleteAll handles the HTTP request for deleting all pages
|
||||
func (h *DeletePagesHandler) HandleDeleteAll(w http.ResponseWriter, r *http.Request) {
|
||||
// Get site information from context (populated by API key middleware)
|
||||
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
|
||||
if !ok || !isAuthenticated {
|
||||
h.logger.Error("site not authenticated in context")
|
||||
httperror.ProblemUnauthorized(w, "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract site ID and tenant ID from context
|
||||
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
|
||||
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
|
||||
|
||||
h.logger.Info("delete all pages request",
|
||||
zap.String("tenant_id", tenantIDStr),
|
||||
zap.String("site_id", siteIDStr))
|
||||
|
||||
// Parse IDs
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid tenant ID")
|
||||
return
|
||||
}
|
||||
|
||||
siteID, err := gocql.ParseUUID(siteIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid site ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid site ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Call service
|
||||
output, err := h.deleteService.DeleteAllPages(r.Context(), tenantID, siteID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to delete all pages",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteIDStr))
|
||||
|
||||
// Check for specific errors
|
||||
if err.Error() == "site not found" {
|
||||
httperror.ProblemNotFound(w, "site not found")
|
||||
return
|
||||
}
|
||||
if err.Error() == "site is not verified" {
|
||||
httperror.ProblemForbidden(w, "site is not verified")
|
||||
return
|
||||
}
|
||||
|
||||
httperror.ProblemInternalServerError(w, "failed to delete all pages")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := pagedto.DeleteResponse{
|
||||
DeletedCount: output.DeletedCount,
|
||||
DeindexedCount: output.DeindexedCount,
|
||||
Message: output.Message,
|
||||
}
|
||||
|
||||
h.logger.Info("all pages deleted successfully",
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.Int("deleted_count", output.DeletedCount))
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
pagedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/page"
|
||||
pageservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/page"
|
||||
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
|
||||
)
|
||||
|
||||
// SearchPagesHandler handles page search from WordPress plugin
|
||||
type SearchPagesHandler struct {
|
||||
searchService pageservice.SearchPagesService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideSearchPagesHandler creates a new SearchPagesHandler
|
||||
func ProvideSearchPagesHandler(
|
||||
searchService pageservice.SearchPagesService,
|
||||
logger *zap.Logger,
|
||||
) *SearchPagesHandler {
|
||||
return &SearchPagesHandler{
|
||||
searchService: searchService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for searching pages
|
||||
// This endpoint is protected by API key middleware
|
||||
func (h *SearchPagesHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get site information from context (populated by API key middleware)
|
||||
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
|
||||
if !ok || !isAuthenticated {
|
||||
h.logger.Error("site not authenticated in context")
|
||||
httperror.ProblemUnauthorized(w, "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract site ID and tenant ID from context
|
||||
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
|
||||
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
|
||||
|
||||
h.logger.Info("search pages request",
|
||||
zap.String("tenant_id", tenantIDStr),
|
||||
zap.String("site_id", siteIDStr))
|
||||
|
||||
// Parse IDs
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid tenant ID")
|
||||
return
|
||||
}
|
||||
|
||||
siteID, err := gocql.ParseUUID(siteIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid site ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid site ID")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-436: Validate Content-Type before parsing
|
||||
if err := httpvalidation.ValidateJSONContentType(r); err != nil {
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req pagedto.SearchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.ProblemBadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if req.Query == "" {
|
||||
httperror.ProblemBadRequest(w, "query is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert DTO to use case input
|
||||
input := &pageusecase.SearchPagesInput{
|
||||
Query: req.Query,
|
||||
Limit: req.Limit,
|
||||
Offset: req.Offset,
|
||||
Filter: req.Filter,
|
||||
}
|
||||
|
||||
// Call service
|
||||
output, err := h.searchService.SearchPages(r.Context(), tenantID, siteID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to search pages",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("query", req.Query))
|
||||
|
||||
// Check for specific errors
|
||||
if err.Error() == "site not found" {
|
||||
httperror.ProblemNotFound(w, "site not found")
|
||||
return
|
||||
}
|
||||
if err.Error() == "site is not verified" {
|
||||
httperror.ProblemForbidden(w, "site is not verified")
|
||||
return
|
||||
}
|
||||
|
||||
httperror.ProblemInternalServerError(w, "failed to search pages")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := pagedto.SearchResponse{
|
||||
Hits: output.Hits.([]map[string]interface{}),
|
||||
Query: output.Query,
|
||||
ProcessingTimeMs: output.ProcessingTimeMs,
|
||||
TotalHits: output.TotalHits,
|
||||
Limit: output.Limit,
|
||||
Offset: output.Offset,
|
||||
}
|
||||
|
||||
h.logger.Info("pages searched successfully",
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("query", req.Query),
|
||||
zap.Int64("total_hits", output.TotalHits))
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
|
||||
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
|
||||
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// StatusHandler handles WordPress plugin status/verification requests
|
||||
type StatusHandler struct {
|
||||
getSiteService siteservice.GetSiteService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideStatusHandler creates a new StatusHandler
|
||||
func ProvideStatusHandler(
|
||||
getSiteService siteservice.GetSiteService,
|
||||
logger *zap.Logger,
|
||||
) *StatusHandler {
|
||||
return &StatusHandler{
|
||||
getSiteService: getSiteService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// StatusResponse represents the response for plugin status endpoint
|
||||
type StatusResponse struct {
|
||||
// Core Identity
|
||||
SiteID string `json:"site_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Domain string `json:"domain"`
|
||||
SiteURL string `json:"site_url"`
|
||||
|
||||
// Status & Verification
|
||||
Status string `json:"status"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
VerificationStatus string `json:"verification_status"` // "pending" or "verified"
|
||||
VerificationToken string `json:"verification_token,omitempty"` // Only if pending
|
||||
VerificationInstructions string `json:"verification_instructions,omitempty"` // Only if pending
|
||||
|
||||
// Storage (usage tracking only - no quotas)
|
||||
StorageUsedBytes int64 `json:"storage_used_bytes"`
|
||||
|
||||
// Usage tracking (monthly, resets for billing)
|
||||
SearchRequestsCount int64 `json:"search_requests_count"`
|
||||
MonthlyPagesIndexed int64 `json:"monthly_pages_indexed"`
|
||||
TotalPagesIndexed int64 `json:"total_pages_indexed"` // All-time stat
|
||||
|
||||
// Search
|
||||
SearchIndexName string `json:"search_index_name"`
|
||||
|
||||
// Additional Info
|
||||
APIKeyPrefix string `json:"api_key_prefix"`
|
||||
APIKeyLastFour string `json:"api_key_last_four"`
|
||||
PluginVersion string `json:"plugin_version,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for plugin status verification
|
||||
// This endpoint is protected by API key middleware, so if we reach here, the API key is valid
|
||||
func (h *StatusHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get site information from context (populated by API key middleware)
|
||||
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
|
||||
if !ok || !isAuthenticated {
|
||||
h.logger.Error("site not authenticated in context")
|
||||
httperror.ProblemUnauthorized(w, "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract site ID and tenant ID from context
|
||||
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
|
||||
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
|
||||
|
||||
h.logger.Info("plugin status check",
|
||||
zap.String("site_id", siteIDStr))
|
||||
|
||||
// Parse UUIDs
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid tenant ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch full site details from database
|
||||
siteOutput, err := h.getSiteService.GetSite(r.Context(), tenantID, &siteusecase.GetSiteInput{
|
||||
ID: siteIDStr,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get site details", zap.Error(err))
|
||||
httperror.ProblemInternalServerError(w, "failed to retrieve site details")
|
||||
return
|
||||
}
|
||||
|
||||
site := siteOutput.Site
|
||||
|
||||
// Build response with full site details
|
||||
response := StatusResponse{
|
||||
SiteID: site.ID.String(),
|
||||
TenantID: site.TenantID.String(),
|
||||
Domain: site.Domain,
|
||||
SiteURL: site.SiteURL,
|
||||
|
||||
Status: site.Status,
|
||||
IsVerified: site.IsVerified,
|
||||
VerificationStatus: getVerificationStatus(site),
|
||||
|
||||
StorageUsedBytes: site.StorageUsedBytes,
|
||||
SearchRequestsCount: site.SearchRequestsCount,
|
||||
MonthlyPagesIndexed: site.MonthlyPagesIndexed,
|
||||
TotalPagesIndexed: site.TotalPagesIndexed,
|
||||
|
||||
SearchIndexName: site.SearchIndexName,
|
||||
|
||||
APIKeyPrefix: site.APIKeyPrefix,
|
||||
APIKeyLastFour: site.APIKeyLastFour,
|
||||
PluginVersion: site.PluginVersion,
|
||||
Language: site.Language,
|
||||
Timezone: site.Timezone,
|
||||
|
||||
Message: "API key is valid",
|
||||
}
|
||||
|
||||
// If site is not verified and requires verification, include instructions
|
||||
if site.RequiresVerification() && !site.IsVerified {
|
||||
response.VerificationToken = site.VerificationToken
|
||||
response.VerificationInstructions = generateVerificationInstructions(site)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
||||
// getVerificationStatus returns the verification status string
|
||||
func getVerificationStatus(site *domainsite.Site) string {
|
||||
if site.IsVerified {
|
||||
return "verified"
|
||||
}
|
||||
return "pending"
|
||||
}
|
||||
|
||||
// generateVerificationInstructions generates DNS verification instructions
|
||||
func generateVerificationInstructions(site *domainsite.Site) string {
|
||||
return fmt.Sprintf(
|
||||
"To verify ownership of %s, add this DNS TXT record:\n\n"+
|
||||
"Host/Name: %s\n"+
|
||||
"Type: TXT\n"+
|
||||
"Value: maplepress-verify=%s\n\n"+
|
||||
"Instructions:\n"+
|
||||
"1. Log in to your domain registrar (GoDaddy, Namecheap, Cloudflare, etc.)\n"+
|
||||
"2. Find DNS settings for your domain\n"+
|
||||
"3. Add a new TXT record with the values above\n"+
|
||||
"4. Wait 5-10 minutes for DNS propagation\n"+
|
||||
"5. Click 'Verify Domain' in your WordPress plugin settings",
|
||||
site.Domain,
|
||||
site.Domain,
|
||||
site.VerificationToken,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
pagedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/page"
|
||||
pageservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/page"
|
||||
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
|
||||
)
|
||||
|
||||
// SyncPagesHandler handles page synchronization from WordPress plugin
|
||||
type SyncPagesHandler struct {
|
||||
syncService pageservice.SyncPagesService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideSyncPagesHandler creates a new SyncPagesHandler
|
||||
func ProvideSyncPagesHandler(
|
||||
syncService pageservice.SyncPagesService,
|
||||
logger *zap.Logger,
|
||||
) *SyncPagesHandler {
|
||||
return &SyncPagesHandler{
|
||||
syncService: syncService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for syncing pages
|
||||
// This endpoint is protected by API key middleware
|
||||
func (h *SyncPagesHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get site information from context (populated by API key middleware)
|
||||
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
|
||||
if !ok || !isAuthenticated {
|
||||
h.logger.Error("site not authenticated in context")
|
||||
httperror.ProblemUnauthorized(w, "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract site ID and tenant ID from context
|
||||
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
|
||||
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
|
||||
|
||||
h.logger.Info("sync pages request",
|
||||
zap.String("tenant_id", tenantIDStr),
|
||||
zap.String("site_id", siteIDStr))
|
||||
|
||||
// Parse IDs
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid tenant ID")
|
||||
return
|
||||
}
|
||||
|
||||
siteID, err := gocql.ParseUUID(siteIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid site ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid site ID")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-436: Validate Content-Type before parsing
|
||||
if err := httpvalidation.ValidateJSONContentType(r); err != nil {
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req pagedto.SyncRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.ProblemBadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-20: Comprehensive input validation
|
||||
if err := req.Validate(); err != nil {
|
||||
h.logger.Warn("sync pages request validation failed", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Convert DTO to use case input
|
||||
pages := make([]pageusecase.SyncPageInput, len(req.Pages))
|
||||
for i, p := range req.Pages {
|
||||
pages[i] = pageusecase.SyncPageInput{
|
||||
PageID: p.PageID,
|
||||
Title: p.Title,
|
||||
Content: p.Content,
|
||||
Excerpt: p.Excerpt,
|
||||
URL: p.URL,
|
||||
Status: p.Status,
|
||||
PostType: p.PostType,
|
||||
Author: p.Author,
|
||||
PublishedAt: p.PublishedAt,
|
||||
ModifiedAt: p.ModifiedAt,
|
||||
}
|
||||
}
|
||||
|
||||
input := &pageusecase.SyncPagesInput{
|
||||
Pages: pages,
|
||||
}
|
||||
|
||||
// Call service
|
||||
output, err := h.syncService.SyncPages(r.Context(), tenantID, siteID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to sync pages",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteIDStr))
|
||||
|
||||
// Check for specific errors
|
||||
if err.Error() == "site not found" {
|
||||
httperror.ProblemNotFound(w, "site not found")
|
||||
return
|
||||
}
|
||||
if err.Error() == "site is not verified" {
|
||||
httperror.ProblemForbidden(w, "site is not verified")
|
||||
return
|
||||
}
|
||||
|
||||
httperror.ProblemInternalServerError(w, "failed to sync pages")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := pagedto.SyncResponse{
|
||||
SyncedCount: output.SyncedCount,
|
||||
IndexedCount: output.IndexedCount,
|
||||
FailedPages: output.FailedPages,
|
||||
Message: output.Message,
|
||||
}
|
||||
|
||||
h.logger.Info("pages synced successfully",
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.Int("synced_count", output.SyncedCount),
|
||||
zap.Int("indexed_count", output.IndexedCount))
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
pagedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/page"
|
||||
pageservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/page"
|
||||
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// SyncStatusHandler handles sync status requests from WordPress plugin
|
||||
type SyncStatusHandler struct {
|
||||
statusService pageservice.SyncStatusService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideSyncStatusHandler creates a new SyncStatusHandler
|
||||
func ProvideSyncStatusHandler(
|
||||
statusService pageservice.SyncStatusService,
|
||||
logger *zap.Logger,
|
||||
) *SyncStatusHandler {
|
||||
return &SyncStatusHandler{
|
||||
statusService: statusService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for getting sync status
|
||||
// This endpoint is protected by API key middleware
|
||||
func (h *SyncStatusHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get site information from context (populated by API key middleware)
|
||||
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
|
||||
if !ok || !isAuthenticated {
|
||||
h.logger.Error("site not authenticated in context")
|
||||
httperror.ProblemUnauthorized(w, "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract site ID and tenant ID from context
|
||||
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
|
||||
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
|
||||
|
||||
h.logger.Info("sync status request",
|
||||
zap.String("tenant_id", tenantIDStr),
|
||||
zap.String("site_id", siteIDStr))
|
||||
|
||||
// Parse IDs
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid tenant ID")
|
||||
return
|
||||
}
|
||||
|
||||
siteID, err := gocql.ParseUUID(siteIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid site ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid site ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Call service
|
||||
output, err := h.statusService.GetSyncStatus(r.Context(), tenantID, siteID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get sync status",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteIDStr))
|
||||
|
||||
// Check for specific errors
|
||||
if err.Error() == "site not found" {
|
||||
httperror.ProblemNotFound(w, "site not found")
|
||||
return
|
||||
}
|
||||
if err.Error() == "site is not verified" {
|
||||
httperror.ProblemForbidden(w, "site is not verified")
|
||||
return
|
||||
}
|
||||
|
||||
httperror.ProblemInternalServerError(w, "failed to get sync status")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := pagedto.StatusResponse{
|
||||
SiteID: output.SiteID,
|
||||
TotalPages: output.TotalPages,
|
||||
PublishedPages: output.PublishedPages,
|
||||
DraftPages: output.DraftPages,
|
||||
LastSyncedAt: output.LastSyncedAt,
|
||||
PagesIndexedMonth: output.PagesIndexedMonth,
|
||||
SearchRequestsMonth: output.SearchRequestsMonth,
|
||||
LastResetAt: output.LastResetAt,
|
||||
SearchIndexStatus: output.SearchIndexStatus,
|
||||
SearchIndexDocCount: output.SearchIndexDocCount,
|
||||
}
|
||||
|
||||
h.logger.Info("sync status retrieved successfully",
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.Int64("total_pages", output.TotalPages))
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
||||
// HandleGetPageDetails handles the HTTP request for getting page details
|
||||
func (h *SyncStatusHandler) HandleGetPageDetails(w http.ResponseWriter, r *http.Request) {
|
||||
// Get site information from context (populated by API key middleware)
|
||||
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
|
||||
if !ok || !isAuthenticated {
|
||||
h.logger.Error("site not authenticated in context")
|
||||
httperror.ProblemUnauthorized(w, "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract site ID and tenant ID from context
|
||||
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
|
||||
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
|
||||
|
||||
// Get page ID from URL path parameter
|
||||
pageID := r.PathValue("page_id")
|
||||
|
||||
h.logger.Info("get page details request",
|
||||
zap.String("tenant_id", tenantIDStr),
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("page_id", pageID))
|
||||
|
||||
// Parse IDs
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid tenant ID")
|
||||
return
|
||||
}
|
||||
|
||||
siteID, err := gocql.ParseUUID(siteIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid site ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid site ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate page ID
|
||||
if pageID == "" {
|
||||
httperror.ProblemBadRequest(w, "page_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Call service
|
||||
input := &pageusecase.GetPageDetailsInput{
|
||||
PageID: pageID,
|
||||
}
|
||||
|
||||
output, err := h.statusService.GetPageDetails(r.Context(), tenantID, siteID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get page details",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("page_id", pageID))
|
||||
|
||||
// Check for specific errors
|
||||
if err.Error() == "page not found" {
|
||||
httperror.ProblemNotFound(w, "page not found")
|
||||
return
|
||||
}
|
||||
|
||||
httperror.ProblemInternalServerError(w, "failed to get page details")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := pagedto.PageDetailsResponse{
|
||||
PageID: output.PageID,
|
||||
Title: output.Title,
|
||||
Excerpt: output.Excerpt,
|
||||
URL: output.URL,
|
||||
Status: output.Status,
|
||||
PostType: output.PostType,
|
||||
Author: output.Author,
|
||||
PublishedAt: output.PublishedAt,
|
||||
ModifiedAt: output.ModifiedAt,
|
||||
IndexedAt: output.IndexedAt,
|
||||
MeilisearchDocID: output.MeilisearchDocID,
|
||||
IsIndexed: output.IsIndexed,
|
||||
}
|
||||
|
||||
h.logger.Info("page details retrieved successfully",
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("page_id", pageID))
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
|
||||
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// PluginVerifyHandler handles domain verification from WordPress plugin
|
||||
type PluginVerifyHandler struct {
|
||||
service siteservice.VerifySiteService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvidePluginVerifyHandler creates a new PluginVerifyHandler
|
||||
func ProvidePluginVerifyHandler(service siteservice.VerifySiteService, logger *zap.Logger) *PluginVerifyHandler {
|
||||
return &PluginVerifyHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyResponse represents the verification response
|
||||
type VerifyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for verifying a site via plugin API
|
||||
// Uses API key authentication (site context from middleware)
|
||||
func (h *PluginVerifyHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get tenant ID and site ID from API key middleware context
|
||||
tenantIDStr, ok := r.Context().Value(constants.SiteTenantID).(string)
|
||||
if !ok {
|
||||
h.logger.Error("tenant ID not found in context")
|
||||
httperror.ProblemUnauthorized(w, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
siteIDStr, ok := r.Context().Value(constants.SiteID).(string)
|
||||
if !ok {
|
||||
h.logger.Error("site ID not found in context")
|
||||
httperror.ProblemUnauthorized(w, "Site context required")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid tenant ID")
|
||||
return
|
||||
}
|
||||
|
||||
siteID, err := gocql.ParseUUID(siteIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid site ID", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid site ID")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("plugin verify request",
|
||||
zap.String("tenant_id", tenantID.String()),
|
||||
zap.String("site_id", siteID.String()))
|
||||
|
||||
// Call verification service (reuses existing DNS verification logic)
|
||||
input := &siteusecase.VerifySiteInput{}
|
||||
output, err := h.service.VerifySite(r.Context(), tenantID, siteID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("verification failed",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteID.String()))
|
||||
|
||||
// Provide user-friendly error messages
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "DNS TXT record not found") {
|
||||
httperror.ProblemBadRequest(w, "DNS TXT record not found. Please ensure you've added the verification record to your domain's DNS settings and wait 5-10 minutes for propagation.")
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "DNS lookup timed out") || strings.Contains(errMsg, "timeout") {
|
||||
httperror.ProblemBadRequest(w, "DNS lookup timed out. Please check that your domain's DNS is properly configured.")
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "domain not found") {
|
||||
httperror.ProblemBadRequest(w, "Domain not found. Please check that your domain is properly registered and DNS is active.")
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "DNS verification failed") {
|
||||
httperror.ProblemBadRequest(w, "DNS verification failed. Please check your DNS settings and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
httperror.ProblemInternalServerError(w, "Failed to verify site. Please try again later.")
|
||||
return
|
||||
}
|
||||
|
||||
// Success response
|
||||
response := VerifyResponse{
|
||||
Success: output.Success,
|
||||
Status: output.Status,
|
||||
Message: output.Message,
|
||||
}
|
||||
|
||||
h.logger.Info("site verified successfully via plugin",
|
||||
zap.String("site_id", siteID.String()))
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// VersionHandler handles version requests from WordPress plugin
|
||||
type VersionHandler struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideVersionHandler creates a new VersionHandler
|
||||
func ProvideVersionHandler(logger *zap.Logger) *VersionHandler {
|
||||
return &VersionHandler{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// VersionResponse represents the response for the version endpoint
|
||||
type VersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
APIVersion string `json:"api_version"`
|
||||
Environment string `json:"environment"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Handle processes GET /api/v1/plugin/version requests
|
||||
func (h *VersionHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("Version endpoint called",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
response := VersionResponse{
|
||||
Version: "1.0.0",
|
||||
APIVersion: "v1",
|
||||
Environment: "production", // Could be made configurable via environment variable
|
||||
Status: "operational",
|
||||
}
|
||||
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
|
||||
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
|
||||
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/dns"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
|
||||
)
|
||||
|
||||
// CreateHandler handles site creation HTTP requests
|
||||
type CreateHandler struct {
|
||||
service siteservice.CreateSiteService
|
||||
config *config.Config
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideCreateHandler creates a new CreateHandler
|
||||
func ProvideCreateHandler(service siteservice.CreateSiteService, cfg *config.Config, logger *zap.Logger) *CreateHandler {
|
||||
return &CreateHandler{
|
||||
service: service,
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for creating a site
|
||||
// Requires JWT authentication and tenant context
|
||||
func (h *CreateHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get tenant ID from context (populated by TenantMiddleware)
|
||||
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
|
||||
if !ok {
|
||||
h.logger.Error("tenant ID not found in context")
|
||||
httperror.ProblemUnauthorized(w, "tenant context required")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "invalid tenant ID")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-436: Validate Content-Type before parsing
|
||||
if err := httpvalidation.ValidateJSONContentType(r); err != nil {
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req sitedto.CreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.ProblemBadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-20: Comprehensive input validation
|
||||
if err := req.Validate(); err != nil {
|
||||
h.logger.Warn("site creation request validation failed", zap.Error(err))
|
||||
|
||||
// Check if it's a structured validation error (RFC 9457 format)
|
||||
if validationErr, ok := err.(*sitedto.ValidationErrors); ok {
|
||||
httperror.ValidationError(w, validationErr.Errors, "One or more validation errors occurred")
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for non-structured errors
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Extract domain from site URL
|
||||
parsedURL, err := url.Parse(req.SiteURL)
|
||||
if err != nil {
|
||||
h.logger.Warn("failed to parse site URL", zap.Error(err), zap.String("site_url", req.SiteURL))
|
||||
httperror.ValidationError(w, map[string][]string{
|
||||
"site_url": {"Invalid URL format. Please provide a valid URL (e.g., https://example.com)."},
|
||||
}, "One or more validation errors occurred")
|
||||
return
|
||||
}
|
||||
|
||||
domain := parsedURL.Hostname()
|
||||
if domain == "" {
|
||||
h.logger.Warn("could not extract domain from site URL", zap.String("site_url", req.SiteURL))
|
||||
httperror.ValidationError(w, map[string][]string{
|
||||
"site_url": {"Could not extract domain from URL. Please provide a valid URL with a hostname."},
|
||||
}, "One or more validation errors occurred")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine test mode based on environment
|
||||
testMode := h.config.App.IsTestMode()
|
||||
|
||||
h.logger.Info("creating site",
|
||||
zap.String("domain", domain),
|
||||
zap.String("site_url", req.SiteURL),
|
||||
zap.String("environment", h.config.App.Environment),
|
||||
zap.Bool("test_mode", testMode))
|
||||
|
||||
// Map DTO to use case input
|
||||
input := &siteusecase.CreateSiteInput{
|
||||
Domain: domain,
|
||||
SiteURL: req.SiteURL,
|
||||
TestMode: testMode,
|
||||
}
|
||||
|
||||
// Call service
|
||||
output, err := h.service.CreateSite(r.Context(), tenantID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create site",
|
||||
zap.Error(err),
|
||||
zap.String("domain", domain),
|
||||
zap.String("site_url", req.SiteURL),
|
||||
zap.String("tenant_id", tenantID.String()))
|
||||
|
||||
// Check for domain already exists error
|
||||
if err.Error() == "domain already exists" {
|
||||
httperror.ProblemConflict(w, "This domain is already registered. Each domain can only be registered once.")
|
||||
return
|
||||
}
|
||||
|
||||
httperror.ProblemInternalServerError(w, "Failed to create site. Please try again later.")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := sitedto.CreateResponse{
|
||||
ID: output.ID,
|
||||
Domain: output.Domain,
|
||||
SiteURL: output.SiteURL,
|
||||
APIKey: output.APIKey, // Only shown once!
|
||||
Status: output.Status,
|
||||
VerificationToken: output.VerificationToken,
|
||||
SearchIndexName: output.SearchIndexName,
|
||||
VerificationInstructions: dns.GetVerificationInstructions(output.Domain, output.VerificationToken),
|
||||
}
|
||||
|
||||
h.logger.Info("site created successfully",
|
||||
zap.String("site_id", output.ID),
|
||||
zap.String("domain", output.Domain),
|
||||
zap.String("tenant_id", tenantID.String()))
|
||||
|
||||
// Write response with pretty JSON
|
||||
httpresponse.Created(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
|
||||
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// DeleteHandler handles site deletion HTTP requests
|
||||
type DeleteHandler struct {
|
||||
service siteservice.DeleteSiteService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideDeleteHandler creates a new DeleteHandler
|
||||
func ProvideDeleteHandler(service siteservice.DeleteSiteService, logger *zap.Logger) *DeleteHandler {
|
||||
return &DeleteHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for deleting a site
|
||||
// Requires JWT authentication and tenant context
|
||||
func (h *DeleteHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get tenant ID from context
|
||||
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
|
||||
if !ok {
|
||||
h.logger.Error("tenant ID not found in context")
|
||||
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
|
||||
return
|
||||
}
|
||||
|
||||
// Get site ID from path parameter
|
||||
siteIDStr := r.PathValue("id")
|
||||
if siteIDStr == "" {
|
||||
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate UUID format
|
||||
if _, err := gocql.ParseUUID(siteIDStr); err != nil {
|
||||
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
|
||||
return
|
||||
}
|
||||
|
||||
// Call service
|
||||
input := &siteusecase.DeleteSiteInput{SiteID: siteIDStr}
|
||||
_, err = h.service.DeleteSite(r.Context(), tenantID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to delete site",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("tenant_id", tenantID.String()))
|
||||
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("site deleted successfully",
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("tenant_id", tenantID.String()))
|
||||
|
||||
// Write response
|
||||
httpresponse.OK(w, map[string]string{
|
||||
"message": "site deleted successfully",
|
||||
"site_id": siteIDStr,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
|
||||
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
|
||||
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// GetHandler handles getting a site by ID
|
||||
type GetHandler struct {
|
||||
service siteservice.GetSiteService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGetHandler creates a new GetHandler
|
||||
func ProvideGetHandler(service siteservice.GetSiteService, logger *zap.Logger) *GetHandler {
|
||||
return &GetHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for getting a site by ID
|
||||
// Requires JWT authentication and tenant context
|
||||
func (h *GetHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get tenant ID from context
|
||||
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
|
||||
if !ok {
|
||||
h.logger.Error("tenant ID not found in context")
|
||||
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
|
||||
return
|
||||
}
|
||||
|
||||
// Get site ID from path parameter
|
||||
siteIDStr := r.PathValue("id")
|
||||
if siteIDStr == "" {
|
||||
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate UUID format
|
||||
if _, err := gocql.ParseUUID(siteIDStr); err != nil {
|
||||
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
|
||||
return
|
||||
}
|
||||
|
||||
// Call service
|
||||
input := &siteusecase.GetSiteInput{ID: siteIDStr}
|
||||
output, err := h.service.GetSite(r.Context(), tenantID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get site",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("tenant_id", tenantID.String()))
|
||||
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := sitedto.GetResponse{
|
||||
ID: output.Site.ID.String(),
|
||||
TenantID: output.Site.TenantID.String(),
|
||||
Domain: output.Site.Domain,
|
||||
SiteURL: output.Site.SiteURL,
|
||||
APIKeyPrefix: output.Site.APIKeyPrefix,
|
||||
APIKeyLastFour: output.Site.APIKeyLastFour,
|
||||
Status: output.Site.Status,
|
||||
IsVerified: output.Site.IsVerified,
|
||||
SearchIndexName: output.Site.SearchIndexName,
|
||||
TotalPagesIndexed: output.Site.TotalPagesIndexed,
|
||||
LastIndexedAt: output.Site.LastIndexedAt,
|
||||
PluginVersion: output.Site.PluginVersion,
|
||||
StorageUsedBytes: output.Site.StorageUsedBytes,
|
||||
SearchRequestsCount: output.Site.SearchRequestsCount,
|
||||
MonthlyPagesIndexed: output.Site.MonthlyPagesIndexed,
|
||||
LastResetAt: output.Site.LastResetAt,
|
||||
Language: output.Site.Language,
|
||||
Timezone: output.Site.Timezone,
|
||||
Notes: output.Site.Notes,
|
||||
CreatedAt: output.Site.CreatedAt,
|
||||
UpdatedAt: output.Site.UpdatedAt,
|
||||
}
|
||||
|
||||
// Write response with pretty JSON
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
|
||||
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
|
||||
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// ListHandler handles listing sites for a tenant
|
||||
type ListHandler struct {
|
||||
service siteservice.ListSitesService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideListHandler creates a new ListHandler
|
||||
func ProvideListHandler(service siteservice.ListSitesService, logger *zap.Logger) *ListHandler {
|
||||
return &ListHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for listing sites
|
||||
// Requires JWT authentication and tenant context
|
||||
func (h *ListHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get tenant ID from context
|
||||
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
|
||||
if !ok {
|
||||
h.logger.Error("tenant ID not found in context")
|
||||
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
|
||||
return
|
||||
}
|
||||
|
||||
// Call service
|
||||
input := &siteusecase.ListSitesInput{}
|
||||
output, err := h.service.ListSites(r.Context(), tenantID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list sites",
|
||||
zap.Error(err),
|
||||
zap.String("tenant_id", tenantID.String()))
|
||||
httperror.ProblemInternalServerError(w, "Failed to retrieve your sites. Please try again later.")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
items := make([]sitedto.SiteListItem, len(output.Sites))
|
||||
for i, s := range output.Sites {
|
||||
items[i] = sitedto.SiteListItem{
|
||||
ID: s.ID.String(),
|
||||
Domain: s.Domain,
|
||||
Status: s.Status,
|
||||
IsVerified: s.IsVerified,
|
||||
TotalPagesIndexed: s.TotalPagesIndexed,
|
||||
CreatedAt: s.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
response := sitedto.ListResponse{
|
||||
Sites: items,
|
||||
Total: len(items),
|
||||
}
|
||||
|
||||
// Write response with pretty JSON
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
|
||||
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
|
||||
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// RotateAPIKeyHandler handles API key rotation HTTP requests
|
||||
type RotateAPIKeyHandler struct {
|
||||
service siteservice.RotateAPIKeyService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideRotateAPIKeyHandler creates a new RotateAPIKeyHandler
|
||||
func ProvideRotateAPIKeyHandler(service siteservice.RotateAPIKeyService, logger *zap.Logger) *RotateAPIKeyHandler {
|
||||
return &RotateAPIKeyHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for rotating a site's API key
|
||||
// Requires JWT authentication and tenant context
|
||||
func (h *RotateAPIKeyHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get tenant ID from context
|
||||
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
|
||||
if !ok {
|
||||
h.logger.Error("tenant ID not found in context")
|
||||
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
|
||||
return
|
||||
}
|
||||
|
||||
// Get site ID from path parameter
|
||||
siteIDStr := r.PathValue("id")
|
||||
if siteIDStr == "" {
|
||||
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate UUID format
|
||||
if _, err := gocql.ParseUUID(siteIDStr); err != nil {
|
||||
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
|
||||
return
|
||||
}
|
||||
|
||||
// Call service
|
||||
input := &siteusecase.RotateAPIKeyInput{SiteID: siteIDStr}
|
||||
output, err := h.service.RotateAPIKey(r.Context(), tenantID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to rotate API key",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("tenant_id", tenantID.String()))
|
||||
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := sitedto.RotateAPIKeyResponse{
|
||||
NewAPIKey: output.NewAPIKey, // Only shown once!
|
||||
OldKeyLastFour: output.OldKeyLastFour,
|
||||
RotatedAt: output.RotatedAt,
|
||||
}
|
||||
|
||||
h.logger.Info("API key rotated successfully",
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("tenant_id", tenantID.String()))
|
||||
|
||||
// Write response
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
|
||||
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// VerifySiteHandler handles site verification HTTP requests
|
||||
type VerifySiteHandler struct {
|
||||
service siteservice.VerifySiteService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideVerifySiteHandler creates a new VerifySiteHandler
|
||||
func ProvideVerifySiteHandler(service siteservice.VerifySiteService, logger *zap.Logger) *VerifySiteHandler {
|
||||
return &VerifySiteHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyResponse represents the verification response
|
||||
// No request body needed - verification is done via DNS TXT record
|
||||
type VerifyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// contains checks if a string contains a substring (helper for error checking)
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
|
||||
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
|
||||
findSubstring(s, substr)))
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for verifying a site
|
||||
// Requires JWT authentication and tenant context
|
||||
func (h *VerifySiteHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Get tenant ID from context
|
||||
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
|
||||
if !ok {
|
||||
h.logger.Error("tenant ID not found in context")
|
||||
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := gocql.ParseUUID(tenantIDStr)
|
||||
if err != nil {
|
||||
h.logger.Error("invalid tenant ID format", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
|
||||
return
|
||||
}
|
||||
|
||||
// Get site ID from path parameter
|
||||
siteIDStr := r.PathValue("id")
|
||||
if siteIDStr == "" {
|
||||
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate UUID format
|
||||
siteID, err := gocql.ParseUUID(siteIDStr)
|
||||
if err != nil {
|
||||
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
|
||||
return
|
||||
}
|
||||
|
||||
// No request body needed - DNS verification uses the token stored in the site entity
|
||||
// Call service with empty input
|
||||
input := &siteusecase.VerifySiteInput{}
|
||||
output, err := h.service.VerifySite(r.Context(), tenantID, siteID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to verify site",
|
||||
zap.Error(err),
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("tenant_id", tenantID.String()))
|
||||
|
||||
// Check for specific error types
|
||||
errMsg := err.Error()
|
||||
|
||||
if errMsg == "site not found" {
|
||||
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
|
||||
return
|
||||
}
|
||||
|
||||
// DNS-related errors
|
||||
if contains(errMsg, "DNS TXT record not found") {
|
||||
httperror.ProblemBadRequest(w, "DNS TXT record not found. Please add the verification record to your domain's DNS settings and wait 5-10 minutes for propagation.")
|
||||
return
|
||||
}
|
||||
if contains(errMsg, "DNS lookup timed out") {
|
||||
httperror.ProblemBadRequest(w, "DNS lookup timed out. Please check that your domain's DNS is properly configured.")
|
||||
return
|
||||
}
|
||||
if contains(errMsg, "domain not found") {
|
||||
httperror.ProblemBadRequest(w, "Domain not found. Please check that your domain is properly registered and DNS is active.")
|
||||
return
|
||||
}
|
||||
if contains(errMsg, "DNS verification failed") {
|
||||
httperror.ProblemBadRequest(w, "DNS verification failed. Please check your DNS settings and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
httperror.ProblemInternalServerError(w, "Failed to verify site. Please try again later.")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response
|
||||
response := VerifyResponse{
|
||||
Success: output.Success,
|
||||
Status: output.Status,
|
||||
Message: output.Message,
|
||||
}
|
||||
|
||||
h.logger.Info("site verified successfully",
|
||||
zap.String("site_id", siteIDStr),
|
||||
zap.String("tenant_id", tenantID.String()))
|
||||
|
||||
// Write response
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
tenantdto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/tenant"
|
||||
tenantservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/tenant"
|
||||
tenantusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/tenant"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
)
|
||||
|
||||
// CreateHandler handles tenant creation HTTP requests
|
||||
type CreateHandler struct {
|
||||
service tenantservice.CreateTenantService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideCreateHandler creates a new CreateHandler
|
||||
func ProvideCreateHandler(service tenantservice.CreateTenantService, logger *zap.Logger) *CreateHandler {
|
||||
return &CreateHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for creating a tenant
|
||||
// Note: This endpoint does NOT require tenant middleware since we're creating a tenant
|
||||
// Security: CWE-20, CWE-79, CWE-117 - Comprehensive input validation and sanitization
|
||||
func (h *CreateHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// CWE-436: Enforce strict Content-Type validation
|
||||
if err := httpvalidation.ValidateJSONContentTypeStrict(r); err != nil {
|
||||
h.logger.Warn("invalid content type", zap.String("content_type", r.Header.Get("Content-Type")))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req tenantdto.CreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Warn("invalid request body", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, "Invalid request body format. Please check your JSON syntax.")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-20: Comprehensive input validation
|
||||
if err := req.Validate(); err != nil {
|
||||
h.logger.Warn("tenant creation validation failed", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user context for logging
|
||||
userID := "unknown"
|
||||
if uid := r.Context().Value(constants.SessionUserID); uid != nil {
|
||||
if userIDUint, ok := uid.(uint64); ok {
|
||||
userID = fmt.Sprintf("%d", userIDUint)
|
||||
}
|
||||
}
|
||||
|
||||
// CWE-532: Safe logging with hashed PII
|
||||
h.logger.Info("creating tenant",
|
||||
zap.String("user_id", userID),
|
||||
logger.TenantSlugHash(req.Slug))
|
||||
|
||||
// Map DTO to use case input
|
||||
input := &tenantusecase.CreateTenantInput{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
}
|
||||
|
||||
// Call service
|
||||
output, err := h.service.CreateTenant(r.Context(), input)
|
||||
if err != nil {
|
||||
// CWE-532: Log with safe identifiers
|
||||
h.logger.Error("failed to create tenant",
|
||||
zap.Error(err),
|
||||
zap.String("user_id", userID),
|
||||
logger.TenantSlugHash(req.Slug))
|
||||
httperror.ProblemInternalServerError(w, "Failed to create tenant. Please try again later.")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-532: Log successful creation
|
||||
h.logger.Info("tenant created successfully",
|
||||
zap.String("user_id", userID),
|
||||
zap.String("tenant_id", output.ID),
|
||||
logger.TenantSlugHash(output.Slug))
|
||||
|
||||
// Map to response DTO
|
||||
response := tenantdto.CreateResponse{
|
||||
ID: output.ID,
|
||||
Name: output.Name,
|
||||
Slug: output.Slug,
|
||||
Status: output.Status,
|
||||
CreatedAt: output.CreatedAt,
|
||||
}
|
||||
|
||||
// Write response
|
||||
httpresponse.Created(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
tenantdto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/tenant"
|
||||
tenantservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/tenant"
|
||||
tenantusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/tenant"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
|
||||
)
|
||||
|
||||
// GetHandler handles getting a tenant by ID or slug
|
||||
type GetHandler struct {
|
||||
service tenantservice.GetTenantService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGetHandler creates a new GetHandler
|
||||
func ProvideGetHandler(service tenantservice.GetTenantService, logger *zap.Logger) *GetHandler {
|
||||
return &GetHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleByID handles the HTTP request for getting a tenant by ID
|
||||
// Security: CWE-20 - Path parameter validation
|
||||
func (h *GetHandler) HandleByID(w http.ResponseWriter, r *http.Request) {
|
||||
// CWE-20: Validate UUID path parameter
|
||||
id, err := validation.ValidatePathUUID(r, "id")
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid tenant ID", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Call service
|
||||
input := &tenantusecase.GetTenantInput{ID: id}
|
||||
output, err := h.service.GetTenant(r.Context(), input)
|
||||
if err != nil {
|
||||
// CWE-532: Don't log full error details to prevent information leakage
|
||||
h.logger.Debug("failed to get tenant",
|
||||
zap.String("tenant_id", id),
|
||||
zap.Error(err))
|
||||
httperror.ProblemNotFound(w, "The requested tenant could not be found.")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-532: Safe logging
|
||||
h.logger.Info("tenant retrieved",
|
||||
zap.String("tenant_id", output.ID),
|
||||
logger.TenantSlugHash(output.Slug))
|
||||
|
||||
// Map to response DTO
|
||||
response := tenantdto.GetResponse{
|
||||
ID: output.ID,
|
||||
Name: output.Name,
|
||||
Slug: output.Slug,
|
||||
Status: output.Status,
|
||||
CreatedAt: output.CreatedAt,
|
||||
UpdatedAt: output.UpdatedAt,
|
||||
}
|
||||
|
||||
// Write response
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
||||
// HandleBySlug handles the HTTP request for getting a tenant by slug
|
||||
// Security: CWE-20 - Path parameter validation
|
||||
func (h *GetHandler) HandleBySlug(w http.ResponseWriter, r *http.Request) {
|
||||
// CWE-20: Validate slug path parameter
|
||||
slug, err := validation.ValidatePathSlug(r, "slug")
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid tenant slug", zap.Error(err))
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Call service
|
||||
input := &tenantusecase.GetTenantBySlugInput{Slug: slug}
|
||||
output, err := h.service.GetTenantBySlug(r.Context(), input)
|
||||
if err != nil {
|
||||
// CWE-532: Don't log full error details to prevent information leakage
|
||||
h.logger.Debug("failed to get tenant by slug",
|
||||
logger.TenantSlugHash(slug),
|
||||
zap.Error(err))
|
||||
httperror.ProblemNotFound(w, "The requested tenant could not be found.")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-532: Safe logging
|
||||
h.logger.Info("tenant retrieved by slug",
|
||||
zap.String("tenant_id", output.ID),
|
||||
logger.TenantSlugHash(output.Slug))
|
||||
|
||||
// Map to response DTO
|
||||
response := tenantdto.GetResponse{
|
||||
ID: output.ID,
|
||||
Name: output.Name,
|
||||
Slug: output.Slug,
|
||||
Status: output.Status,
|
||||
CreatedAt: output.CreatedAt,
|
||||
UpdatedAt: output.UpdatedAt,
|
||||
}
|
||||
|
||||
// Write response
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
userdto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/middleware"
|
||||
userservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/user"
|
||||
userusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
|
||||
)
|
||||
|
||||
// CreateHandler handles user creation HTTP requests
|
||||
type CreateHandler struct {
|
||||
service userservice.CreateUserService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideCreateHandler creates a new CreateHandler
|
||||
func ProvideCreateHandler(service userservice.CreateUserService, logger *zap.Logger) *CreateHandler {
|
||||
return &CreateHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for creating a user
|
||||
func (h *CreateHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract tenant from context (set by middleware)
|
||||
tenantID, err := middleware.GetTenantID(r.Context())
|
||||
if err != nil {
|
||||
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
|
||||
return
|
||||
}
|
||||
|
||||
// CWE-436: Validate Content-Type before parsing
|
||||
if err := httpvalidation.ValidateJSONContentType(r); err != nil {
|
||||
httperror.ProblemBadRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req userdto.CreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.ProblemBadRequest(w, "Invalid request body format. Please check your JSON syntax.")
|
||||
return
|
||||
}
|
||||
|
||||
// Map DTO to use case input
|
||||
input := &userusecase.CreateUserInput{
|
||||
Email: req.Email,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
}
|
||||
|
||||
// Call service
|
||||
output, err := h.service.CreateUser(r.Context(), tenantID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create user", zap.Error(err))
|
||||
httperror.ProblemInternalServerError(w, "Failed to create user. Please try again later.")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := userdto.CreateResponse{
|
||||
ID: output.ID,
|
||||
Email: output.Email,
|
||||
Name: output.Name,
|
||||
CreatedAt: output.CreatedAt,
|
||||
}
|
||||
|
||||
// Write response
|
||||
httpresponse.Created(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
userdto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/middleware"
|
||||
userservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/user"
|
||||
userusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// GetHandler handles getting a user by ID
|
||||
type GetHandler struct {
|
||||
service userservice.GetUserService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// ProvideGetHandler creates a new GetHandler
|
||||
func ProvideGetHandler(service userservice.GetUserService, logger *zap.Logger) *GetHandler {
|
||||
return &GetHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle handles the HTTP request for getting a user
|
||||
func (h *GetHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract tenant from context
|
||||
tenantID, err := middleware.GetTenantID(r.Context())
|
||||
if err != nil {
|
||||
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from path parameter
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
httperror.ProblemBadRequest(w, "User ID is required in the request path.")
|
||||
return
|
||||
}
|
||||
|
||||
// Call service
|
||||
input := &userusecase.GetUserInput{ID: id}
|
||||
output, err := h.service.GetUser(r.Context(), tenantID, input)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get user", zap.Error(err))
|
||||
httperror.ProblemNotFound(w, "The requested user could not be found.")
|
||||
return
|
||||
}
|
||||
|
||||
// Map to response DTO
|
||||
response := userdto.GetResponse{
|
||||
ID: output.ID,
|
||||
Email: output.Email,
|
||||
Name: output.Name,
|
||||
CreatedAt: output.CreatedAt,
|
||||
UpdatedAt: output.UpdatedAt,
|
||||
}
|
||||
|
||||
// Write response
|
||||
httpresponse.OK(w, response)
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LoggerMiddleware logs HTTP requests
|
||||
func LoggerMiddleware(logger *zap.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Wrap response writer to capture status code
|
||||
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
logger.Info("HTTP request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.Int("status", wrapped.statusCode),
|
||||
zap.Duration("duration", duration),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
|
||||
)
|
||||
|
||||
// TenantMiddleware extracts tenant ID from JWT session context and adds to context
|
||||
// This middleware must be used after JWT middleware in the chain
|
||||
func TenantMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get tenant from JWT session context (set by JWT middleware)
|
||||
tenantID, ok := r.Context().Value(constants.SessionTenantID).(string)
|
||||
if !ok || tenantID == "" {
|
||||
http.Error(w, "tenant context required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Add to context with constants.ContextKeyTenantID for handler access
|
||||
ctx := context.WithValue(r.Context(), constants.ContextKeyTenantID, tenantID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetTenantID retrieves tenant ID from context
|
||||
func GetTenantID(ctx context.Context) (string, error) {
|
||||
tenantID, ok := ctx.Value(constants.ContextKeyTenantID).(string)
|
||||
if !ok || tenantID == "" {
|
||||
return "", errors.New("tenant_id not found in context")
|
||||
}
|
||||
return tenantID, nil
|
||||
}
|
||||
490
cloud/maplepress-backend/internal/interface/http/server.go
Normal file
490
cloud/maplepress-backend/internal/interface/http/server.go
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
|
||||
httpmw "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/http/middleware"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/admin"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/healthcheck"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/plugin"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/site"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/tenant"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/middleware"
|
||||
)
|
||||
|
||||
// Server represents the HTTP server
|
||||
type Server struct {
|
||||
server *http.Server
|
||||
logger *zap.Logger
|
||||
jwtMiddleware *httpmw.JWTMiddleware
|
||||
apikeyMiddleware *httpmw.APIKeyMiddleware
|
||||
rateLimitMiddlewares *httpmw.RateLimitMiddlewares // CWE-770: Registration and auth endpoints rate limiting
|
||||
securityHeadersMiddleware *httpmw.SecurityHeadersMiddleware
|
||||
requestSizeLimitMw *httpmw.RequestSizeLimitMiddleware
|
||||
config *config.Config
|
||||
healthHandler *healthcheck.Handler
|
||||
registerHandler *gateway.RegisterHandler
|
||||
loginHandler *gateway.LoginHandler
|
||||
refreshTokenHandler *gateway.RefreshTokenHandler
|
||||
helloHandler *gateway.HelloHandler
|
||||
meHandler *gateway.MeHandler
|
||||
createTenantHandler *tenant.CreateHandler
|
||||
getTenantHandler *tenant.GetHandler
|
||||
createUserHandler *user.CreateHandler
|
||||
getUserHandler *user.GetHandler
|
||||
createSiteHandler *site.CreateHandler
|
||||
getSiteHandler *site.GetHandler
|
||||
listSitesHandler *site.ListHandler
|
||||
deleteSiteHandler *site.DeleteHandler
|
||||
rotateSiteAPIKeyHandler *site.RotateAPIKeyHandler
|
||||
verifySiteHandler *site.VerifySiteHandler
|
||||
pluginStatusHandler *plugin.StatusHandler
|
||||
pluginVerifyHandler *plugin.PluginVerifyHandler
|
||||
pluginVersionHandler *plugin.VersionHandler
|
||||
syncPagesHandler *plugin.SyncPagesHandler
|
||||
searchPagesHandler *plugin.SearchPagesHandler
|
||||
deletePagesHandler *plugin.DeletePagesHandler
|
||||
syncStatusHandler *plugin.SyncStatusHandler
|
||||
unlockAccountHandler *admin.UnlockAccountHandler
|
||||
accountStatusHandler *admin.AccountStatusHandler
|
||||
}
|
||||
|
||||
// ProvideServer creates a new HTTP server
|
||||
func ProvideServer(
|
||||
cfg *config.Config,
|
||||
logger *zap.Logger,
|
||||
jwtMiddleware *httpmw.JWTMiddleware,
|
||||
apikeyMiddleware *httpmw.APIKeyMiddleware,
|
||||
rateLimitMiddlewares *httpmw.RateLimitMiddlewares,
|
||||
securityHeadersMiddleware *httpmw.SecurityHeadersMiddleware,
|
||||
requestSizeLimitMw *httpmw.RequestSizeLimitMiddleware,
|
||||
healthHandler *healthcheck.Handler,
|
||||
registerHandler *gateway.RegisterHandler,
|
||||
loginHandler *gateway.LoginHandler,
|
||||
refreshTokenHandler *gateway.RefreshTokenHandler,
|
||||
helloHandler *gateway.HelloHandler,
|
||||
meHandler *gateway.MeHandler,
|
||||
createTenantHandler *tenant.CreateHandler,
|
||||
getTenantHandler *tenant.GetHandler,
|
||||
createUserHandler *user.CreateHandler,
|
||||
getUserHandler *user.GetHandler,
|
||||
createSiteHandler *site.CreateHandler,
|
||||
getSiteHandler *site.GetHandler,
|
||||
listSitesHandler *site.ListHandler,
|
||||
deleteSiteHandler *site.DeleteHandler,
|
||||
rotateSiteAPIKeyHandler *site.RotateAPIKeyHandler,
|
||||
verifySiteHandler *site.VerifySiteHandler,
|
||||
pluginStatusHandler *plugin.StatusHandler,
|
||||
pluginVerifyHandler *plugin.PluginVerifyHandler,
|
||||
pluginVersionHandler *plugin.VersionHandler,
|
||||
syncPagesHandler *plugin.SyncPagesHandler,
|
||||
searchPagesHandler *plugin.SearchPagesHandler,
|
||||
deletePagesHandler *plugin.DeletePagesHandler,
|
||||
syncStatusHandler *plugin.SyncStatusHandler,
|
||||
unlockAccountHandler *admin.UnlockAccountHandler,
|
||||
accountStatusHandler *admin.AccountStatusHandler,
|
||||
) *Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
s := &Server{
|
||||
logger: logger,
|
||||
jwtMiddleware: jwtMiddleware,
|
||||
apikeyMiddleware: apikeyMiddleware,
|
||||
rateLimitMiddlewares: rateLimitMiddlewares,
|
||||
securityHeadersMiddleware: securityHeadersMiddleware,
|
||||
requestSizeLimitMw: requestSizeLimitMw,
|
||||
config: cfg,
|
||||
healthHandler: healthHandler,
|
||||
registerHandler: registerHandler,
|
||||
loginHandler: loginHandler,
|
||||
refreshTokenHandler: refreshTokenHandler,
|
||||
helloHandler: helloHandler,
|
||||
meHandler: meHandler,
|
||||
createTenantHandler: createTenantHandler,
|
||||
getTenantHandler: getTenantHandler,
|
||||
createUserHandler: createUserHandler,
|
||||
getUserHandler: getUserHandler,
|
||||
createSiteHandler: createSiteHandler,
|
||||
getSiteHandler: getSiteHandler,
|
||||
listSitesHandler: listSitesHandler,
|
||||
deleteSiteHandler: deleteSiteHandler,
|
||||
rotateSiteAPIKeyHandler: rotateSiteAPIKeyHandler,
|
||||
verifySiteHandler: verifySiteHandler,
|
||||
pluginStatusHandler: pluginStatusHandler,
|
||||
pluginVerifyHandler: pluginVerifyHandler,
|
||||
pluginVersionHandler: pluginVersionHandler,
|
||||
syncPagesHandler: syncPagesHandler,
|
||||
searchPagesHandler: searchPagesHandler,
|
||||
deletePagesHandler: deletePagesHandler,
|
||||
syncStatusHandler: syncStatusHandler,
|
||||
unlockAccountHandler: unlockAccountHandler,
|
||||
accountStatusHandler: accountStatusHandler,
|
||||
}
|
||||
|
||||
// Register routes
|
||||
s.registerRoutes(mux)
|
||||
|
||||
// Create HTTP server
|
||||
// CWE-770: Configure timeouts to prevent resource exhaustion
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
|
||||
Handler: s.applyMiddleware(mux),
|
||||
ReadTimeout: cfg.HTTP.ReadTimeout,
|
||||
WriteTimeout: cfg.HTTP.WriteTimeout,
|
||||
IdleTimeout: cfg.HTTP.IdleTimeout,
|
||||
}
|
||||
|
||||
logger.Info("✓ HTTP server configured",
|
||||
zap.String("address", s.server.Addr),
|
||||
zap.Duration("read_timeout", cfg.HTTP.ReadTimeout),
|
||||
zap.Duration("write_timeout", cfg.HTTP.WriteTimeout),
|
||||
zap.Int64("max_body_size", cfg.HTTP.MaxRequestBodySize))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// registerRoutes registers all HTTP routes
|
||||
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
||||
// ===== PUBLIC ROUTES (No authentication, no tenant) =====
|
||||
// Health check
|
||||
mux.HandleFunc("GET /health", s.healthHandler.Handle)
|
||||
|
||||
// Version endpoint - public API for checking backend version
|
||||
mux.HandleFunc("GET /api/v1/version", s.pluginVersionHandler.Handle)
|
||||
|
||||
// Public gateway routes (registration, login, etc.)
|
||||
// CWE-770: Apply request size limits and rate limiting
|
||||
// Apply small size limit (1MB) for registration/login endpoints
|
||||
if s.config.RateLimit.RegistrationEnabled {
|
||||
mux.HandleFunc("POST /api/v1/register",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyRegistrationRateLimit(s.registerHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
} else {
|
||||
mux.HandleFunc("POST /api/v1/register",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.registerHandler.Handle),
|
||||
).ServeHTTP)
|
||||
}
|
||||
mux.HandleFunc("POST /api/v1/login",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.loginHandler.Handle),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("POST /api/v1/refresh",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.refreshTokenHandler.Handle),
|
||||
).ServeHTTP)
|
||||
|
||||
// ===== AUTHENTICATED ROUTES (JWT only, no tenant context) =====
|
||||
// Gateway routes
|
||||
// CWE-770: Apply small size limit (1MB) and generic rate limiting for hello endpoint
|
||||
if s.config.RateLimit.GenericEnabled {
|
||||
mux.HandleFunc("POST /api/v1/hello",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthOnlyWithGenericRateLimit(s.helloHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
} else {
|
||||
mux.HandleFunc("POST /api/v1/hello",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthOnly(s.helloHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
}
|
||||
|
||||
// CWE-770: Apply generic rate limiting to /me endpoint to prevent profile enumeration and DoS
|
||||
if s.config.RateLimit.GenericEnabled {
|
||||
mux.HandleFunc("GET /api/v1/me",
|
||||
s.applyAuthOnlyWithGenericRateLimit(s.meHandler.Handle))
|
||||
} else {
|
||||
mux.HandleFunc("GET /api/v1/me", s.applyAuthOnly(s.meHandler.Handle))
|
||||
}
|
||||
|
||||
// Tenant management routes - these operate at system/admin level
|
||||
// CWE-770: Apply small size limit (1MB) and generic rate limiting for tenant creation
|
||||
if s.config.RateLimit.GenericEnabled {
|
||||
mux.HandleFunc("POST /api/v1/tenants",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthOnlyWithGenericRateLimit(s.createTenantHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("GET /api/v1/tenants/{id}", s.applyAuthOnlyWithGenericRateLimit(s.getTenantHandler.HandleByID))
|
||||
mux.HandleFunc("GET /api/v1/tenants/slug/{slug}", s.applyAuthOnlyWithGenericRateLimit(s.getTenantHandler.HandleBySlug))
|
||||
} else {
|
||||
mux.HandleFunc("POST /api/v1/tenants",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthOnly(s.createTenantHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("GET /api/v1/tenants/{id}", s.applyAuthOnly(s.getTenantHandler.HandleByID))
|
||||
mux.HandleFunc("GET /api/v1/tenants/slug/{slug}", s.applyAuthOnly(s.getTenantHandler.HandleBySlug))
|
||||
}
|
||||
|
||||
// ===== TENANT-SCOPED ROUTES (JWT + Tenant context) =====
|
||||
// User routes - these operate within a tenant context
|
||||
// CWE-770: Apply small size limit (1MB) and generic rate limiting for user creation
|
||||
if s.config.RateLimit.GenericEnabled {
|
||||
mux.HandleFunc("POST /api/v1/users",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthAndTenantWithGenericRateLimit(s.createUserHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("GET /api/v1/users/{id}", s.applyAuthAndTenantWithGenericRateLimit(s.getUserHandler.Handle))
|
||||
} else {
|
||||
mux.HandleFunc("POST /api/v1/users",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthAndTenant(s.createUserHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("GET /api/v1/users/{id}", s.applyAuthAndTenant(s.getUserHandler.Handle))
|
||||
}
|
||||
|
||||
// Site management routes - JWT authenticated, tenant-scoped
|
||||
// CWE-770: Apply small size limit (1MB) and generic rate limiting for site management
|
||||
if s.config.RateLimit.GenericEnabled {
|
||||
mux.HandleFunc("POST /api/v1/sites",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthAndTenantWithGenericRateLimit(s.createSiteHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("GET /api/v1/sites", s.applyAuthAndTenantWithGenericRateLimit(s.listSitesHandler.Handle))
|
||||
mux.HandleFunc("GET /api/v1/sites/{id}", s.applyAuthAndTenantWithGenericRateLimit(s.getSiteHandler.Handle))
|
||||
mux.HandleFunc("DELETE /api/v1/sites/{id}", s.applyAuthAndTenantWithGenericRateLimit(s.deleteSiteHandler.Handle))
|
||||
mux.HandleFunc("POST /api/v1/sites/{id}/rotate-api-key",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthAndTenantWithGenericRateLimit(s.rotateSiteAPIKeyHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("POST /api/v1/sites/{id}/verify",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthAndTenantWithGenericRateLimit(s.verifySiteHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
} else {
|
||||
mux.HandleFunc("POST /api/v1/sites",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthAndTenant(s.createSiteHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("GET /api/v1/sites", s.applyAuthAndTenant(s.listSitesHandler.Handle))
|
||||
mux.HandleFunc("GET /api/v1/sites/{id}", s.applyAuthAndTenant(s.getSiteHandler.Handle))
|
||||
mux.HandleFunc("DELETE /api/v1/sites/{id}", s.applyAuthAndTenant(s.deleteSiteHandler.Handle))
|
||||
mux.HandleFunc("POST /api/v1/sites/{id}/rotate-api-key",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthAndTenant(s.rotateSiteAPIKeyHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("POST /api/v1/sites/{id}/verify",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthAndTenant(s.verifySiteHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
}
|
||||
|
||||
// ===== ADMIN ROUTES (JWT authenticated) =====
|
||||
// CWE-307: Admin endpoints for account lockout management
|
||||
// CWE-770: Apply small size limit (1MB) and generic rate limiting for admin endpoints
|
||||
if s.config.RateLimit.GenericEnabled {
|
||||
mux.HandleFunc("POST /api/v1/admin/unlock-account",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthOnlyWithGenericRateLimit(s.unlockAccountHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("GET /api/v1/admin/account-status", s.applyAuthOnlyWithGenericRateLimit(s.accountStatusHandler.Handle))
|
||||
} else {
|
||||
mux.HandleFunc("POST /api/v1/admin/unlock-account",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAuthOnly(s.unlockAccountHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("GET /api/v1/admin/account-status", s.applyAuthOnly(s.accountStatusHandler.Handle))
|
||||
}
|
||||
|
||||
// ===== WORDPRESS PLUGIN API ROUTES (API Key authentication) =====
|
||||
// CWE-770: Apply lenient site-based rate limiting to protect core business endpoints
|
||||
// Default: 1000 requests/hour per site (very lenient for high-volume legitimate traffic)
|
||||
|
||||
if s.config.RateLimit.PluginAPIEnabled {
|
||||
// Plugin status/verification - with rate limiting
|
||||
mux.HandleFunc("GET /api/v1/plugin/status", s.applyAPIKeyAuthWithPluginRateLimit(s.pluginStatusHandler.Handle))
|
||||
|
||||
// Plugin domain verification endpoint
|
||||
mux.HandleFunc("POST /api/v1/plugin/verify",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAPIKeyAuthWithPluginRateLimit(s.pluginVerifyHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
|
||||
// Page sync and search routes
|
||||
// CWE-770: Apply larger size limit (50MB) for page sync (bulk operations) + rate limiting
|
||||
mux.HandleFunc("POST /api/v1/plugin/pages/sync",
|
||||
s.requestSizeLimitMw.LimitLarge()(
|
||||
http.HandlerFunc(s.applyAPIKeyAuthWithPluginRateLimit(s.syncPagesHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
// Apply medium limit (5MB) for search and delete operations + rate limiting
|
||||
mux.HandleFunc("POST /api/v1/plugin/pages/search",
|
||||
s.requestSizeLimitMw.LimitMedium()(
|
||||
http.HandlerFunc(s.applyAPIKeyAuthWithPluginRateLimit(s.searchPagesHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("DELETE /api/v1/plugin/pages",
|
||||
s.requestSizeLimitMw.LimitMedium()(
|
||||
http.HandlerFunc(s.applyAPIKeyAuthWithPluginRateLimit(s.deletePagesHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("DELETE /api/v1/plugin/pages/all", s.applyAPIKeyAuthWithPluginRateLimit(s.deletePagesHandler.HandleDeleteAll))
|
||||
mux.HandleFunc("GET /api/v1/plugin/pages/status", s.applyAPIKeyAuthWithPluginRateLimit(s.syncStatusHandler.Handle))
|
||||
mux.HandleFunc("GET /api/v1/plugin/pages/{page_id}", s.applyAPIKeyAuthWithPluginRateLimit(s.syncStatusHandler.HandleGetPageDetails))
|
||||
} else {
|
||||
// Plugin endpoints without rate limiting (not recommended for production)
|
||||
mux.HandleFunc("GET /api/v1/plugin/status", s.applyAPIKeyAuth(s.pluginStatusHandler.Handle))
|
||||
|
||||
// Plugin domain verification endpoint
|
||||
mux.HandleFunc("POST /api/v1/plugin/verify",
|
||||
s.requestSizeLimitMw.LimitSmall()(
|
||||
http.HandlerFunc(s.applyAPIKeyAuth(s.pluginVerifyHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
|
||||
mux.HandleFunc("POST /api/v1/plugin/pages/sync",
|
||||
s.requestSizeLimitMw.LimitLarge()(
|
||||
http.HandlerFunc(s.applyAPIKeyAuth(s.syncPagesHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("POST /api/v1/plugin/pages/search",
|
||||
s.requestSizeLimitMw.LimitMedium()(
|
||||
http.HandlerFunc(s.applyAPIKeyAuth(s.searchPagesHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("DELETE /api/v1/plugin/pages",
|
||||
s.requestSizeLimitMw.LimitMedium()(
|
||||
http.HandlerFunc(s.applyAPIKeyAuth(s.deletePagesHandler.Handle)),
|
||||
).ServeHTTP)
|
||||
mux.HandleFunc("DELETE /api/v1/plugin/pages/all", s.applyAPIKeyAuth(s.deletePagesHandler.HandleDeleteAll))
|
||||
mux.HandleFunc("GET /api/v1/plugin/pages/status", s.applyAPIKeyAuth(s.syncStatusHandler.Handle))
|
||||
mux.HandleFunc("GET /api/v1/plugin/pages/{page_id}", s.applyAPIKeyAuth(s.syncStatusHandler.HandleGetPageDetails))
|
||||
}
|
||||
}
|
||||
|
||||
// applyMiddleware applies global middleware to all routes
|
||||
func (s *Server) applyMiddleware(handler http.Handler) http.Handler {
|
||||
// Apply middleware in order (innermost to outermost)
|
||||
// 1. Logger middleware (logging)
|
||||
// 2. Security headers middleware (CWE-693: Protection Mechanism Failure)
|
||||
handler = middleware.LoggerMiddleware(s.logger)(handler)
|
||||
handler = s.securityHeadersMiddleware.Handler(handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
// applyAuthOnly applies only JWT authentication middleware (no tenant)
|
||||
func (s *Server) applyAuthOnly(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Chain: JWT validation -> Auth check -> Handler
|
||||
s.jwtMiddleware.Handler(
|
||||
s.jwtMiddleware.RequireAuth(
|
||||
http.HandlerFunc(handler),
|
||||
),
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// applyAuthOnlyWithGenericRateLimit applies JWT authentication + generic rate limiting (CWE-770)
|
||||
// Used for authenticated CRUD endpoints (tenant/user/site management, admin, /me, /hello)
|
||||
// Applies user-based rate limiting (extracted from JWT context)
|
||||
func (s *Server) applyAuthOnlyWithGenericRateLimit(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Chain: JWT validation -> Auth check -> Generic rate limit (user-based) -> Handler
|
||||
s.jwtMiddleware.Handler(
|
||||
s.jwtMiddleware.RequireAuth(
|
||||
s.rateLimitMiddlewares.Generic.HandlerWithUserKey(
|
||||
http.HandlerFunc(handler),
|
||||
),
|
||||
),
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// applyAuthAndTenant applies JWT authentication + tenant middleware
|
||||
func (s *Server) applyAuthAndTenant(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Chain: JWT validation -> Auth check -> Tenant -> Handler
|
||||
s.jwtMiddleware.Handler(
|
||||
s.jwtMiddleware.RequireAuth(
|
||||
middleware.TenantMiddleware()(
|
||||
http.HandlerFunc(handler),
|
||||
),
|
||||
),
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// applyAuthAndTenantWithGenericRateLimit applies JWT authentication + tenant + generic rate limiting (CWE-770)
|
||||
// Used for tenant-scoped CRUD endpoints (user/site management)
|
||||
// Applies user-based rate limiting (extracted from JWT context)
|
||||
func (s *Server) applyAuthAndTenantWithGenericRateLimit(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Chain: JWT validation -> Auth check -> Tenant -> Generic rate limit (user-based) -> Handler
|
||||
s.jwtMiddleware.Handler(
|
||||
s.jwtMiddleware.RequireAuth(
|
||||
middleware.TenantMiddleware()(
|
||||
s.rateLimitMiddlewares.Generic.HandlerWithUserKey(
|
||||
http.HandlerFunc(handler),
|
||||
),
|
||||
),
|
||||
),
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// applyAPIKeyAuth applies API key authentication middleware (for WordPress plugin)
|
||||
func (s *Server) applyAPIKeyAuth(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Chain: API key validation -> Require API key -> Handler
|
||||
s.apikeyMiddleware.Handler(
|
||||
s.apikeyMiddleware.RequireAPIKey(
|
||||
http.HandlerFunc(handler),
|
||||
),
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// applyAPIKeyAuthWithPluginRateLimit applies API key authentication + plugin API rate limiting (CWE-770)
|
||||
// Used for WordPress Plugin API endpoints (core business endpoints)
|
||||
// Applies site-based rate limiting (extracted from API key context)
|
||||
func (s *Server) applyAPIKeyAuthWithPluginRateLimit(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Chain: API key validation -> Require API key -> Plugin rate limit (site-based) -> Handler
|
||||
s.apikeyMiddleware.Handler(
|
||||
s.apikeyMiddleware.RequireAPIKey(
|
||||
s.rateLimitMiddlewares.PluginAPI.HandlerWithSiteKey(
|
||||
http.HandlerFunc(handler),
|
||||
),
|
||||
),
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// applyRegistrationRateLimit applies rate limiting middleware for registration (CWE-770)
|
||||
func (s *Server) applyRegistrationRateLimit(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Chain: Rate limit check -> Handler
|
||||
s.rateLimitMiddlewares.Registration.Handler(
|
||||
http.HandlerFunc(handler),
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *Server) Start() error {
|
||||
s.logger.Info("")
|
||||
s.logger.Info("🚀 MaplePress Backend is ready!")
|
||||
s.logger.Info("",
|
||||
zap.String("address", s.server.Addr),
|
||||
zap.String("url", fmt.Sprintf("http://localhost:%s", s.server.Addr[len(s.server.Addr)-4:])))
|
||||
s.logger.Info("")
|
||||
|
||||
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("failed to start server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the HTTP server
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
s.logger.Info("shutting down HTTP server")
|
||||
|
||||
if err := s.server.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("failed to shutdown server: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("HTTP server shut down successfully")
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue