Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -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"
}

View file

@ -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,
})
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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,
)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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,
})
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}