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,122 @@
# MapleFile HTTP Server
Standalone HTTP server for MapleFile backend - completely independent with no Manifold orchestration.
## Architecture
- **Standard Library**: Uses `net/http` with Go 1.22+ routing patterns
- **No Orchestration**: Direct route registration (no `AsRoute()` wrappers)
- **Middleware Stack**: Applied globally with per-route authentication
- **Lifecycle Management**: Integrated with Uber FX for graceful shutdown
## Server Configuration
Configured via environment variables in `.env`:
```env
SERVER_HOST=0.0.0.0
SERVER_PORT=8000
SERVER_READ_TIMEOUT=30s
SERVER_WRITE_TIMEOUT=30s
SERVER_IDLE_TIMEOUT=60s
SERVER_SHUTDOWN_TIMEOUT=10s
```
## Middleware Stack
Applied in this order (outermost to innermost):
1. **Recovery** - Catches panics and returns 500
2. **Logging** - Logs all requests with duration
3. **CORS** - Handles cross-origin requests
4. **Authentication** (per-route) - JWT validation for protected routes
## Route Structure
### Public Routes
- `GET /health` - Health check
- `GET /version` - Version info
- `POST /api/v1/auth/register` - Registration
- `POST /api/v1/auth/login` - Login
### Protected Routes
All `/api/v1/*` routes (except auth) require JWT authentication via:
```
Authorization: Bearer <jwt_token>
```
Key protected endpoints include:
- `GET/PUT/DELETE /api/v1/me` - User profile management
- `POST/GET/PUT/DELETE /api/v1/collections/*` - Collection CRUD
- `POST/GET/PUT/DELETE /api/v1/file/*` - File operations
- `POST /api/v1/invites/send-email` - Send invitation to non-registered user
See `routes.go` for complete endpoint list.
## Handler Registration
Routes are registered in `server.go` -> `registerRoutes()`:
```go
// Public route
s.mux.HandleFunc("GET /health", s.healthCheckHandler)
// Protected route
s.mux.HandleFunc("POST /api/v1/collections",
s.middleware.Attach(s.handlers.CreateCollection))
```
## Starting the Server
The server is started automatically by Uber FX:
```go
fx.New(
fx.Provide(http.NewServer), // Creates and starts server
// ... other providers
)
```
Lifecycle hooks handle:
- **OnStart**: Starts HTTP listener in goroutine
- **OnStop**: Graceful shutdown with timeout
## Response Format
All JSON responses follow this structure:
**Success:**
```json
{
"data": { ... },
"message": "Success"
}
```
**Error:**
```json
{
"error": "Error message",
"code": "ERROR_CODE"
}
```
## Health Checks
```bash
# Basic health check
curl http://localhost:8000/health
# Version check
curl http://localhost:8000/version
```
## Development
Build and run:
```bash
task build
./maplefile-backend daemon
```
The server will start on `http://localhost:8000` by default.

View file

@ -0,0 +1,53 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth/complete_login.go
package auth
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type CompleteLoginHandler struct {
logger *zap.Logger
service svc_auth.CompleteLoginService
}
func NewCompleteLoginHandler(
logger *zap.Logger,
service svc_auth.CompleteLoginService,
) *CompleteLoginHandler {
return &CompleteLoginHandler{
logger: logger.Named("CompleteLoginHandler"),
service: service,
}
}
func (h *CompleteLoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req svc_auth.CompleteLoginRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode complete login request", zap.Error(err))
problem := httperror.NewBadRequestError("Invalid request payload. Expected JSON with 'email', 'challengeId', and 'decryptedData' fields.").
WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
resp, err := h.service.Execute(ctx, &req)
if err != nil {
h.logger.Error("Complete login failed", zap.Error(err))
// Service returns RFC 9457 errors, use RespondWithError to handle them
httperror.RespondWithError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,49 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth/recovery_complete.go
package auth
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type RecoveryCompleteHandler struct {
logger *zap.Logger
service svc_auth.RecoveryCompleteService
}
func NewRecoveryCompleteHandler(
logger *zap.Logger,
service svc_auth.RecoveryCompleteService,
) *RecoveryCompleteHandler {
return &RecoveryCompleteHandler{
logger: logger.Named("RecoveryCompleteHandler"),
service: service,
}
}
func (h *RecoveryCompleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req svc_auth.RecoveryCompleteRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode recovery complete request", zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("payload", "Invalid request payload"))
return
}
resp, err := h.service.Execute(ctx, &req)
if err != nil {
h.logger.Error("Recovery complete failed", zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,49 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth/recovery_initiate.go
package auth
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type RecoveryInitiateHandler struct {
logger *zap.Logger
service svc_auth.RecoveryInitiateService
}
func NewRecoveryInitiateHandler(
logger *zap.Logger,
service svc_auth.RecoveryInitiateService,
) *RecoveryInitiateHandler {
return &RecoveryInitiateHandler{
logger: logger.Named("RecoveryInitiateHandler"),
service: service,
}
}
func (h *RecoveryInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req svc_auth.RecoveryInitiateRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode recovery initiate request", zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("payload", "Invalid request payload"))
return
}
resp, err := h.service.Execute(ctx, &req)
if err != nil {
h.logger.Error("Recovery initiate failed", zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,49 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth/recovery_verify.go
package auth
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type RecoveryVerifyHandler struct {
logger *zap.Logger
service svc_auth.RecoveryVerifyService
}
func NewRecoveryVerifyHandler(
logger *zap.Logger,
service svc_auth.RecoveryVerifyService,
) *RecoveryVerifyHandler {
return &RecoveryVerifyHandler{
logger: logger.Named("RecoveryVerifyHandler"),
service: service,
}
}
func (h *RecoveryVerifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req svc_auth.RecoveryVerifyRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode recovery verify request", zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("payload", "Invalid request payload"))
return
}
resp, err := h.service.Execute(ctx, &req)
if err != nil {
h.logger.Error("Recovery verify failed", zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,49 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth/refresh_token.go
package auth
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type RefreshTokenHandler struct {
logger *zap.Logger
service svc_auth.RefreshTokenService
}
func NewRefreshTokenHandler(
logger *zap.Logger,
service svc_auth.RefreshTokenService,
) *RefreshTokenHandler {
return &RefreshTokenHandler{
logger: logger.Named("RefreshTokenHandler"),
service: service,
}
}
func (h *RefreshTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req svc_auth.RefreshTokenRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode refresh token request", zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("payload", "Invalid request payload"))
return
}
resp, err := h.service.Execute(ctx, &req)
if err != nil {
h.logger.Error("Refresh token failed", zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,77 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth/register.go
package auth
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
// RegisterHandler handles user registration
type RegisterHandler struct {
logger *zap.Logger
service svc_auth.RegisterService
}
// NewRegisterHandler creates a new registration handler
func NewRegisterHandler(
logger *zap.Logger,
service svc_auth.RegisterService,
) *RegisterHandler {
return &RegisterHandler{
logger: logger.Named("RegisterHandler"),
service: service,
}
}
// ServeHTTP handles the HTTP request
func (h *RegisterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract request ID from existing middleware
requestID := httperror.ExtractRequestID(r)
// Decode request
var req svc_auth.RegisterRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode register request", zap.Error(err))
problem := httperror.NewBadRequestError("Invalid request payload: " + err.Error())
problem.WithInstance(r.URL.Path).WithTraceID(requestID)
httperror.RespondWithProblem(w, problem)
return
}
// Call service - service handles validation and returns RFC 9457 errors
resp, err := h.service.Execute(ctx, &req)
if err != nil {
// Check if error is already a ProblemDetail
if problem, ok := err.(*httperror.ProblemDetail); ok {
h.logger.Warn("Registration failed with validation errors",
zap.String("email", validation.MaskEmail(req.Email)),
zap.Int("error_count", len(problem.Errors)))
problem.WithInstance(r.URL.Path).WithTraceID(requestID)
httperror.RespondWithProblem(w, problem)
return
}
// Unexpected error - wrap in internal server error
h.logger.Error("Registration failed with unexpected error",
zap.String("email", validation.MaskEmail(req.Email)),
zap.Error(err))
problem := httperror.NewInternalServerError("Registration failed: " + err.Error())
problem.WithInstance(r.URL.Path).WithTraceID(requestID)
httperror.RespondWithProblem(w, problem)
return
}
// Return success response
h.logger.Info("User registered successfully", zap.String("user_id", resp.UserID))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,53 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth/request_ott.go
package auth
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type RequestOTTHandler struct {
logger *zap.Logger
service svc_auth.RequestOTTService
}
func NewRequestOTTHandler(
logger *zap.Logger,
service svc_auth.RequestOTTService,
) *RequestOTTHandler {
return &RequestOTTHandler{
logger: logger.Named("RequestOTTHandler"),
service: service,
}
}
func (h *RequestOTTHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req svc_auth.RequestOTTRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode request OTT request", zap.Error(err))
problem := httperror.NewBadRequestError("Invalid request payload. Expected JSON with 'email' field.").
WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
resp, err := h.service.Execute(ctx, &req)
if err != nil {
h.logger.Error("Request OTT failed", zap.Error(err))
// Service returns RFC 9457 errors, use RespondWithError to handle them
httperror.RespondWithError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,59 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth/resend_verification.go
package auth
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
// ResendVerificationHandler handles resending verification emails
type ResendVerificationHandler struct {
logger *zap.Logger
service svc_auth.ResendVerificationService
}
// NewResendVerificationHandler creates a new resend verification handler
func NewResendVerificationHandler(
logger *zap.Logger,
service svc_auth.ResendVerificationService,
) *ResendVerificationHandler {
return &ResendVerificationHandler{
logger: logger.Named("ResendVerificationHandler"),
service: service,
}
}
// ServeHTTP handles the HTTP request
func (h *ResendVerificationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Decode request
var req svc_auth.ResendVerificationRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode resend verification request", zap.Error(err))
problem := httperror.NewBadRequestError("Invalid request payload. Expected JSON with 'email' field.").
WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Call service (service now handles validation and returns RFC 9457 errors)
resp, err := h.service.Execute(ctx, &req)
if err != nil {
h.logger.Error("Resend verification failed", zap.Error(err))
// Service returns RFC 9457 errors, use RespondWithError to handle them
httperror.RespondWithError(w, r, err)
return
}
// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,59 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth/verify_email.go
package auth
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
// VerifyEmailHandler handles email verification
type VerifyEmailHandler struct {
logger *zap.Logger
service svc_auth.VerifyEmailService
}
// NewVerifyEmailHandler creates a new verify email handler
func NewVerifyEmailHandler(
logger *zap.Logger,
service svc_auth.VerifyEmailService,
) *VerifyEmailHandler {
return &VerifyEmailHandler{
logger: logger.Named("VerifyEmailHandler"),
service: service,
}
}
// ServeHTTP handles the HTTP request
func (h *VerifyEmailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Decode request
var req svc_auth.VerifyEmailRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode verify email request", zap.Error(err))
problem := httperror.NewBadRequestError("Invalid request payload. Expected JSON with 'code' field.").
WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Call service
resp, err := h.service.Execute(ctx, &req)
if err != nil {
h.logger.Error("Email verification failed", zap.Error(err))
// Service returns RFC 9457 errors, use RespondWithError to handle them
httperror.RespondWithError(w, r, err)
return
}
// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,53 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth/verify_ott.go
package auth
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type VerifyOTTHandler struct {
logger *zap.Logger
service svc_auth.VerifyOTTService
}
func NewVerifyOTTHandler(
logger *zap.Logger,
service svc_auth.VerifyOTTService,
) *VerifyOTTHandler {
return &VerifyOTTHandler{
logger: logger.Named("VerifyOTTHandler"),
service: service,
}
}
func (h *VerifyOTTHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req svc_auth.VerifyOTTRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode verify OTT request", zap.Error(err))
problem := httperror.NewBadRequestError("Invalid request payload. Expected JSON with 'email' and 'ott' fields.").
WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
resp, err := h.service.Execute(ctx, &req)
if err != nil {
h.logger.Error("Verify OTT failed", zap.Error(err))
// Service returns RFC 9457 errors, use RespondWithError to handle them
httperror.RespondWithError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,97 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/blockedemail/create.go
package blockedemail
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_blockedemail "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/blockedemail"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type CreateBlockedEmailHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_blockedemail.CreateBlockedEmailService
middleware middleware.Middleware
}
func NewCreateBlockedEmailHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_blockedemail.CreateBlockedEmailService,
middleware middleware.Middleware,
) *CreateBlockedEmailHTTPHandler {
logger = logger.Named("CreateBlockedEmailHTTPHandler")
return &CreateBlockedEmailHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*CreateBlockedEmailHTTPHandler) Pattern() string {
return "POST /api/v1/me/blocked-emails"
}
func (h *CreateBlockedEmailHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *CreateBlockedEmailHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
) (*svc_blockedemail.CreateBlockedEmailRequestDTO, error) {
var requestData svc_blockedemail.CreateBlockedEmailRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON)
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err))
// Log raw JSON at debug level only to avoid PII exposure in production logs
h.logger.Debug("raw request body for debugging",
zap.String("json", rawJSON.String()))
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
return &requestData, nil
}
func (h *CreateBlockedEmailHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
req, err := h.unmarshalRequest(ctx, r)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,87 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/blockedemail/delete.go
package blockedemail
import (
"encoding/json"
"net/http"
"net/url"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_blockedemail "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/blockedemail"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
type DeleteBlockedEmailHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_blockedemail.DeleteBlockedEmailService
middleware middleware.Middleware
}
func NewDeleteBlockedEmailHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_blockedemail.DeleteBlockedEmailService,
middleware middleware.Middleware,
) *DeleteBlockedEmailHTTPHandler {
logger = logger.Named("DeleteBlockedEmailHTTPHandler")
return &DeleteBlockedEmailHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*DeleteBlockedEmailHTTPHandler) Pattern() string {
return "DELETE /api/v1/me/blocked-emails/{email}"
}
func (h *DeleteBlockedEmailHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *DeleteBlockedEmailHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract email from URL path
emailEncoded := r.PathValue("email")
if emailEncoded == "" {
httperror.RespondWithError(w, r, httperror.NewBadRequestError("Email is required"))
return
}
// URL decode the email using PathUnescape (not QueryUnescape)
// PathUnescape correctly handles %2B as + instead of treating + as space
email, err := url.PathUnescape(emailEncoded)
if err != nil {
h.logger.Error("failed to decode email",
zap.String("encoded_email", validation.MaskEmail(emailEncoded)),
zap.Any("error", err))
httperror.RespondWithError(w, r, httperror.NewBadRequestError("Invalid email format"))
return
}
h.logger.Debug("decoded email from path",
zap.String("encoded", validation.MaskEmail(emailEncoded)),
zap.String("decoded", validation.MaskEmail(email)))
resp, err := h.service.Execute(ctx, email)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,63 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/blockedemail/list.go
package blockedemail
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_blockedemail "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/blockedemail"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ListBlockedEmailsHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_blockedemail.ListBlockedEmailsService
middleware middleware.Middleware
}
func NewListBlockedEmailsHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_blockedemail.ListBlockedEmailsService,
middleware middleware.Middleware,
) *ListBlockedEmailsHTTPHandler {
logger = logger.Named("ListBlockedEmailsHTTPHandler")
return &ListBlockedEmailsHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*ListBlockedEmailsHTTPHandler) Pattern() string {
return "GET /api/v1/me/blocked-emails"
}
func (h *ListBlockedEmailsHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *ListBlockedEmailsHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
resp, err := h.service.Execute(ctx)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,37 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/blockedemail/provider.go
package blockedemail
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_blockedemail "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/blockedemail"
)
func ProvideCreateBlockedEmailHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_blockedemail.CreateBlockedEmailService,
middleware middleware.Middleware,
) *CreateBlockedEmailHTTPHandler {
return NewCreateBlockedEmailHTTPHandler(cfg, logger, service, middleware)
}
func ProvideListBlockedEmailsHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_blockedemail.ListBlockedEmailsService,
middleware middleware.Middleware,
) *ListBlockedEmailsHTTPHandler {
return NewListBlockedEmailsHTTPHandler(cfg, logger, service, middleware)
}
func ProvideDeleteBlockedEmailHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_blockedemail.DeleteBlockedEmailService,
middleware middleware.Middleware,
) *DeleteBlockedEmailHTTPHandler {
return NewDeleteBlockedEmailHTTPHandler(cfg, logger, service, middleware)
}

View file

@ -0,0 +1,96 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/archive.go
package collection
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ArchiveCollectionHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.ArchiveCollectionService
middleware middleware.Middleware
}
func NewArchiveCollectionHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.ArchiveCollectionService,
middleware middleware.Middleware,
) *ArchiveCollectionHTTPHandler {
logger = logger.Named("ArchiveCollectionHTTPHandler")
return &ArchiveCollectionHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*ArchiveCollectionHTTPHandler) Pattern() string {
return "PUT /api/v1/collections/{id}/archive"
}
func (h *ArchiveCollectionHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *ArchiveCollectionHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract collection ID from the URL
collectionIDStr := r.PathValue("id")
if collectionIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Collection ID is required"))
return
}
// Convert string ID to ObjectID
collectionID, err := gocql.ParseUUID(collectionIDStr)
if err != nil {
h.logger.Error("invalid collection ID format",
zap.String("collection_id", collectionIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Invalid collection ID format"))
return
}
// Create request DTO
dtoReq := &svc_collection.ArchiveCollectionRequestDTO{
ID: collectionID,
}
resp, err := h.service.Execute(ctx, dtoReq)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,109 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/create.go
package collection
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type CreateCollectionHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.CreateCollectionService
middleware middleware.Middleware
}
func NewCreateCollectionHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.CreateCollectionService,
middleware middleware.Middleware,
) *CreateCollectionHTTPHandler {
logger = logger.Named("CreateCollectionHTTPHandler")
return &CreateCollectionHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*CreateCollectionHTTPHandler) Pattern() string {
return "POST /api/v1/collections"
}
func (h *CreateCollectionHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *CreateCollectionHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
) (*svc_collection.CreateCollectionRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var requestData svc_collection.CreateCollectionRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("Failed to decode create collection request",
zap.Error(err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewBadRequestError("Invalid request payload. Please check your collection data.")
}
return &requestData, nil
}
func (h *CreateCollectionHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
req, err := h.unmarshalRequest(ctx, r)
if err != nil {
h.logger.Error("Failed to unmarshal create collection request", zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
h.logger.Error("Failed to create collection", zap.Error(err))
// Service returns RFC 9457 errors, use RespondWithError to handle them
httperror.RespondWithError(w, r, err)
return
}
if resp == nil {
h.logger.Error("No collection returned from service")
problem := httperror.NewInternalServerError("Failed to create collection. Please try again.").
WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("Failed to encode collection response", zap.Error(err))
// At this point headers are already sent, log the error but can't send RFC 9457 response
return
}
}

View file

@ -0,0 +1,97 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/find_by_parent.go
package collection
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type FindCollectionsByParentHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.FindCollectionsByParentService
middleware middleware.Middleware
}
func NewFindCollectionsByParentHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.FindCollectionsByParentService,
middleware middleware.Middleware,
) *FindCollectionsByParentHTTPHandler {
logger = logger.Named("FindCollectionsByParentHTTPHandler")
return &FindCollectionsByParentHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*FindCollectionsByParentHTTPHandler) Pattern() string {
return "GET /api/v1/collections/parent/{parent_id}"
}
func (h *FindCollectionsByParentHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *FindCollectionsByParentHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract parent ID from URL parameters
parentIDStr := r.PathValue("parent_id")
if parentIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("parent_id", "Parent ID is required"))
return
}
// Convert string ID to ObjectID
parentID, err := gocql.ParseUUID(parentIDStr)
if err != nil {
h.logger.Error("invalid parent ID format",
zap.String("parent_id", parentIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("parent_id", "Invalid parent ID format"))
return
}
// Create request DTO
req := &svc_collection.FindByParentRequestDTO{
ParentID: parentID,
}
// Call service
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,74 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/find_root_collections.go
package collection
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type FindRootCollectionsHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.FindRootCollectionsService
middleware middleware.Middleware
}
func NewFindRootCollectionsHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.FindRootCollectionsService,
middleware middleware.Middleware,
) *FindRootCollectionsHTTPHandler {
logger = logger.Named("FindRootCollectionsHTTPHandler")
return &FindRootCollectionsHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*FindRootCollectionsHTTPHandler) Pattern() string {
return "GET /api/v1/collections/root"
}
func (h *FindRootCollectionsHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *FindRootCollectionsHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
resp, err := h.service.Execute(ctx)
if err != nil {
h.logger.Error("Failed to find root collections", zap.Error(err))
// Service returns RFC 9457 errors, use RespondWithError to handle them
httperror.RespondWithError(w, r, err)
return
}
if resp == nil {
h.logger.Error("No collections returned from service")
problem := httperror.NewInternalServerError("Failed to retrieve collections. Please try again.").
WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("Failed to encode collections response", zap.Error(err))
// At this point headers are already sent, log the error but can't send RFC 9457 response
return
}
}

View file

@ -0,0 +1,91 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/get.go
package collection
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetCollectionHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.GetCollectionService
middleware middleware.Middleware
}
func NewGetCollectionHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.GetCollectionService,
middleware middleware.Middleware,
) *GetCollectionHTTPHandler {
logger = logger.Named("GetCollectionHTTPHandler")
return &GetCollectionHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*GetCollectionHTTPHandler) Pattern() string {
return "GET /api/v1/collections/{id}"
}
func (h *GetCollectionHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *GetCollectionHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract collection ID from URL parameters
// Assuming Go 1.22+ where r.PathValue is available for patterns like "/items/{id}"
collectionIDStr := r.PathValue("id")
if collectionIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Collection ID is required"))
return
}
// Convert string ID to ObjectID
collectionID, err := gocql.ParseUUID(collectionIDStr)
if err != nil {
h.logger.Error("invalid collection ID format",
zap.String("collection_id", collectionIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Invalid collection ID format"))
return
}
resp, err := h.service.Execute(ctx, collectionID)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,124 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/get_filtered.go
package collection
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetFilteredCollectionsHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.GetFilteredCollectionsService
middleware middleware.Middleware
}
func NewGetFilteredCollectionsHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.GetFilteredCollectionsService,
middleware middleware.Middleware,
) *GetFilteredCollectionsHTTPHandler {
logger = logger.Named("GetFilteredCollectionsHTTPHandler")
return &GetFilteredCollectionsHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*GetFilteredCollectionsHTTPHandler) Pattern() string {
return "GET /api/v1/collections/filtered"
}
func (h *GetFilteredCollectionsHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *GetFilteredCollectionsHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Parse query parameters for filter options
req, err := h.parseFilterOptions(r)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}
// parseFilterOptions parses the query parameters to create the request DTO
func (h *GetFilteredCollectionsHTTPHandler) parseFilterOptions(r *http.Request) (*svc_collection.GetFilteredCollectionsRequestDTO, error) {
req := &svc_collection.GetFilteredCollectionsRequestDTO{
IncludeOwned: true, // Default to including owned collections
IncludeShared: false, // Default to not including shared collections
}
// Parse include_owned parameter
if includeOwnedStr := r.URL.Query().Get("include_owned"); includeOwnedStr != "" {
includeOwned, err := strconv.ParseBool(includeOwnedStr)
if err != nil {
h.logger.Warn("Invalid include_owned parameter",
zap.String("value", includeOwnedStr),
zap.Error(err))
return nil, httperror.NewForBadRequestWithSingleField("include_owned", "Invalid boolean value for include_owned parameter")
}
req.IncludeOwned = includeOwned
}
// Parse include_shared parameter
if includeSharedStr := r.URL.Query().Get("include_shared"); includeSharedStr != "" {
includeShared, err := strconv.ParseBool(includeSharedStr)
if err != nil {
h.logger.Warn("Invalid include_shared parameter",
zap.String("value", includeSharedStr),
zap.Error(err))
return nil, httperror.NewForBadRequestWithSingleField("include_shared", "Invalid boolean value for include_shared parameter")
}
req.IncludeShared = includeShared
}
// Validate that at least one option is enabled
if !req.IncludeOwned && !req.IncludeShared {
return nil, httperror.NewForBadRequestWithSingleField("filter_options", "At least one filter option (include_owned or include_shared) must be enabled")
}
h.logger.Debug("Parsed filter options",
zap.Bool("include_owned", req.IncludeOwned),
zap.Bool("include_shared", req.IncludeShared))
return req, nil
}

View file

@ -0,0 +1,73 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/list_by_user.go
package collection
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ListUserCollectionsHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.ListUserCollectionsService
middleware middleware.Middleware
}
func NewListUserCollectionsHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.ListUserCollectionsService,
middleware middleware.Middleware,
) *ListUserCollectionsHTTPHandler {
logger = logger.Named("ListUserCollectionsHTTPHandler")
return &ListUserCollectionsHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*ListUserCollectionsHTTPHandler) Pattern() string {
return "GET /api/v1/collections"
}
func (h *ListUserCollectionsHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *ListUserCollectionsHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
resp, err := h.service.Execute(ctx)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,73 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/list_shared_with_user.go
package collection
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ListSharedCollectionsHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.ListSharedCollectionsService
middleware middleware.Middleware
}
func NewListSharedCollectionsHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.ListSharedCollectionsService,
middleware middleware.Middleware,
) *ListSharedCollectionsHTTPHandler {
logger = logger.Named("ListSharedCollectionsHTTPHandler")
return &ListSharedCollectionsHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*ListSharedCollectionsHTTPHandler) Pattern() string {
return "GET /api/v1/collections/shared"
}
func (h *ListSharedCollectionsHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *ListSharedCollectionsHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Call service
resp, err := h.service.Execute(ctx)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,129 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/move_collection.go
package collection
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type MoveCollectionHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.MoveCollectionService
middleware middleware.Middleware
}
func NewMoveCollectionHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.MoveCollectionService,
middleware middleware.Middleware,
) *MoveCollectionHTTPHandler {
logger = logger.Named("MoveCollectionHTTPHandler")
return &MoveCollectionHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*MoveCollectionHTTPHandler) Pattern() string {
return "PUT /api/v1/collections/{id}/move"
}
func (h *MoveCollectionHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *MoveCollectionHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
collectionID gocql.UUID,
) (*svc_collection.MoveCollectionRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var requestData svc_collection.MoveCollectionRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
// Set the collection ID from the URL parameter
requestData.CollectionID = collectionID
return &requestData, nil
}
func (h *MoveCollectionHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract collection ID from URL parameters
collectionIDStr := r.PathValue("id")
if collectionIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Collection ID is required"))
return
}
// Convert string ID to ObjectID
collectionID, err := gocql.ParseUUID(collectionIDStr)
if err != nil {
h.logger.Error("invalid collection ID format",
zap.String("collection_id", collectionIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Invalid collection ID format"))
return
}
req, err := h.unmarshalRequest(ctx, r, collectionID)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,146 @@
package collection
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
)
// Wire providers for collection HTTP handlers
func ProvideCreateCollectionHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.CreateCollectionService,
mw middleware.Middleware,
) *CreateCollectionHTTPHandler {
return NewCreateCollectionHTTPHandler(cfg, logger, service, mw)
}
func ProvideGetCollectionHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.GetCollectionService,
mw middleware.Middleware,
) *GetCollectionHTTPHandler {
return NewGetCollectionHTTPHandler(cfg, logger, service, mw)
}
func ProvideListUserCollectionsHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.ListUserCollectionsService,
mw middleware.Middleware,
) *ListUserCollectionsHTTPHandler {
return NewListUserCollectionsHTTPHandler(cfg, logger, service, mw)
}
func ProvideUpdateCollectionHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.UpdateCollectionService,
mw middleware.Middleware,
) *UpdateCollectionHTTPHandler {
return NewUpdateCollectionHTTPHandler(cfg, logger, service, mw)
}
func ProvideSoftDeleteCollectionHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.SoftDeleteCollectionService,
mw middleware.Middleware,
) *SoftDeleteCollectionHTTPHandler {
return NewSoftDeleteCollectionHTTPHandler(cfg, logger, service, mw)
}
func ProvideArchiveCollectionHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.ArchiveCollectionService,
mw middleware.Middleware,
) *ArchiveCollectionHTTPHandler {
return NewArchiveCollectionHTTPHandler(cfg, logger, service, mw)
}
func ProvideRestoreCollectionHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.RestoreCollectionService,
mw middleware.Middleware,
) *RestoreCollectionHTTPHandler {
return NewRestoreCollectionHTTPHandler(cfg, logger, service, mw)
}
func ProvideListSharedCollectionsHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.ListSharedCollectionsService,
mw middleware.Middleware,
) *ListSharedCollectionsHTTPHandler {
return NewListSharedCollectionsHTTPHandler(cfg, logger, service, mw)
}
func ProvideFindRootCollectionsHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.FindRootCollectionsService,
mw middleware.Middleware,
) *FindRootCollectionsHTTPHandler {
return NewFindRootCollectionsHTTPHandler(cfg, logger, service, mw)
}
func ProvideFindCollectionsByParentHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.FindCollectionsByParentService,
mw middleware.Middleware,
) *FindCollectionsByParentHTTPHandler {
return NewFindCollectionsByParentHTTPHandler(cfg, logger, service, mw)
}
func ProvideCollectionSyncHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.GetCollectionSyncDataService,
mw middleware.Middleware,
) *CollectionSyncHTTPHandler {
return NewCollectionSyncHTTPHandler(cfg, logger, service, mw)
}
func ProvideMoveCollectionHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.MoveCollectionService,
mw middleware.Middleware,
) *MoveCollectionHTTPHandler {
return NewMoveCollectionHTTPHandler(cfg, logger, service, mw)
}
func ProvideGetFilteredCollectionsHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.GetFilteredCollectionsService,
mw middleware.Middleware,
) *GetFilteredCollectionsHTTPHandler {
return NewGetFilteredCollectionsHTTPHandler(cfg, logger, service, mw)
}
func ProvideShareCollectionHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.ShareCollectionService,
mw middleware.Middleware,
) *ShareCollectionHTTPHandler {
return NewShareCollectionHTTPHandler(cfg, logger, service, mw)
}
func ProvideRemoveMemberHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_collection.RemoveMemberService,
mw middleware.Middleware,
) *RemoveMemberHTTPHandler {
return NewRemoveMemberHTTPHandler(cfg, logger, service, mw)
}

View file

@ -0,0 +1,148 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/remove_member.go
package collection
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type RemoveMemberHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.RemoveMemberService
middleware middleware.Middleware
}
func NewRemoveMemberHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.RemoveMemberService,
middleware middleware.Middleware,
) *RemoveMemberHTTPHandler {
logger = logger.Named("RemoveMemberHTTPHandler")
return &RemoveMemberHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*RemoveMemberHTTPHandler) Pattern() string {
return "DELETE /api/v1/collections/{id}/members/{user_id}"
}
func (h *RemoveMemberHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *RemoveMemberHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
collectionID gocql.UUID,
recipientID gocql.UUID,
) (*svc_collection.RemoveMemberRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var requestData svc_collection.RemoveMemberRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
// Set the collection ID and recipient ID from the URL parameters
requestData.CollectionID = collectionID
requestData.RecipientID = recipientID
return &requestData, nil
}
func (h *RemoveMemberHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract collection ID from URL parameters
collectionIDStr := r.PathValue("id")
if collectionIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Collection ID is required"))
return
}
// Extract user ID from URL parameters
userIDStr := r.PathValue("user_id")
if userIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("user_id", "User ID is required"))
return
}
// Convert collection ID string to UUID
collectionID, err := gocql.ParseUUID(collectionIDStr)
if err != nil {
h.logger.Error("invalid collection ID format",
zap.String("collection_id", collectionIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Invalid collection ID format"))
return
}
// Convert user ID string to UUID
userID, err := gocql.ParseUUID(userIDStr)
if err != nil {
h.logger.Error("invalid user ID format",
zap.String("user_id", userIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("user_id", "Invalid user ID format"))
return
}
req, err := h.unmarshalRequest(ctx, r, collectionID, userID)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,96 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/restore.go
package collection
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type RestoreCollectionHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.RestoreCollectionService
middleware middleware.Middleware
}
func NewRestoreCollectionHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.RestoreCollectionService,
middleware middleware.Middleware,
) *RestoreCollectionHTTPHandler {
logger = logger.Named("RestoreCollectionHTTPHandler")
return &RestoreCollectionHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*RestoreCollectionHTTPHandler) Pattern() string {
return "PUT /api/v1/collections/{id}/restore"
}
func (h *RestoreCollectionHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *RestoreCollectionHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract collection ID from the URL
collectionIDStr := r.PathValue("id")
if collectionIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Collection ID is required"))
return
}
// Convert string ID to ObjectID
collectionID, err := gocql.ParseUUID(collectionIDStr)
if err != nil {
h.logger.Error("invalid collection ID format",
zap.String("collection_id", collectionIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Invalid collection ID format"))
return
}
// Create request DTO
dtoReq := &svc_collection.RestoreCollectionRequestDTO{
ID: collectionID,
}
resp, err := h.service.Execute(ctx, dtoReq)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,167 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/share_collection.go
package collection
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
type ShareCollectionHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.ShareCollectionService
middleware middleware.Middleware
}
func NewShareCollectionHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.ShareCollectionService,
middleware middleware.Middleware,
) *ShareCollectionHTTPHandler {
logger = logger.Named("ShareCollectionHTTPHandler")
return &ShareCollectionHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*ShareCollectionHTTPHandler) Pattern() string {
return "POST /api/v1/collections/{id}/share"
}
func (h *ShareCollectionHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *ShareCollectionHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
collectionID gocql.UUID,
) (*svc_collection.ShareCollectionRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var requestData svc_collection.ShareCollectionRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("JSON decoding error",
zap.Any("err", err),
zap.String("raw_json", rawJSON.String()),
)
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
// Log the decoded request for debugging (PII masked for security)
h.logger.Debug("decoded share collection request",
zap.String("collection_id_from_url", collectionID.String()),
zap.String("collection_id_from_body", requestData.CollectionID.String()),
zap.String("recipient_id", requestData.RecipientID.String()),
zap.String("recipient_email", validation.MaskEmail(requestData.RecipientEmail)),
zap.String("permission_level", requestData.PermissionLevel),
zap.Int("encrypted_key_length", len(requestData.EncryptedCollectionKey)),
zap.Bool("share_with_descendants", requestData.ShareWithDescendants))
// CRITICAL: Check if encrypted collection key is present in the request
if len(requestData.EncryptedCollectionKey) == 0 {
h.logger.Error("FRONTEND BUG: encrypted_collection_key is missing from request",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", requestData.RecipientID.String()),
zap.String("recipient_email", validation.MaskEmail(requestData.RecipientEmail)))
// Log raw JSON at debug level only to avoid PII exposure in production logs
h.logger.Debug("raw request body for debugging",
zap.String("collection_id", collectionID.String()),
zap.String("raw_json", rawJSON.String()))
} else {
h.logger.Debug("encrypted_collection_key found in request",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", requestData.RecipientID.String()),
zap.Int("encrypted_key_length", len(requestData.EncryptedCollectionKey)))
}
// Set the collection ID from the URL parameter
requestData.CollectionID = collectionID
return &requestData, nil
}
func (h *ShareCollectionHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract collection ID from URL parameters
collectionIDStr := r.PathValue("id")
if collectionIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Collection ID is required"))
return
}
// Convert string ID to ObjectID
collectionID, err := gocql.ParseUUID(collectionIDStr)
if err != nil {
h.logger.Error("invalid collection ID format",
zap.String("collection_id", collectionIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Invalid collection ID format"))
return
}
h.logger.Info("processing share collection request",
zap.String("collection_id", collectionID.String()),
zap.String("method", r.Method),
zap.String("content_type", r.Header.Get("Content-Type")))
req, err := h.unmarshalRequest(ctx, r, collectionID)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Call service
resp, err := h.service.Execute(ctx, req)
if err != nil {
h.logger.Error("share collection service failed",
zap.String("collection_id", collectionID.String()),
zap.String("recipient_id", req.RecipientID.String()),
zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,96 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/softdelete.go
package collection
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type SoftDeleteCollectionHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.SoftDeleteCollectionService
middleware middleware.Middleware
}
func NewSoftDeleteCollectionHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.SoftDeleteCollectionService,
middleware middleware.Middleware,
) *SoftDeleteCollectionHTTPHandler {
logger = logger.Named("SoftDeleteCollectionHTTPHandler")
return &SoftDeleteCollectionHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*SoftDeleteCollectionHTTPHandler) Pattern() string {
return "DELETE /api/v1/collections/{id}"
}
func (h *SoftDeleteCollectionHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *SoftDeleteCollectionHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract collection ID from the URL
collectionIDStr := r.PathValue("id")
if collectionIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Collection ID is required"))
return
}
// Convert string ID to ObjectID
collectionID, err := gocql.ParseUUID(collectionIDStr)
if err != nil {
h.logger.Error("invalid collection ID format",
zap.String("collection_id", collectionIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Invalid collection ID format"))
return
}
// Create request DTO
dtoReq := &svc_collection.SoftDeleteCollectionRequestDTO{
ID: collectionID,
}
resp, err := h.service.Execute(ctx, dtoReq)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,127 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/sync.go
package collection
import (
"encoding/json"
"net/http"
"strconv"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
dom_sync "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type CollectionSyncHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.GetCollectionSyncDataService
middleware middleware.Middleware
}
func NewCollectionSyncHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.GetCollectionSyncDataService,
middleware middleware.Middleware,
) *CollectionSyncHTTPHandler {
logger = logger.Named("CollectionSyncHTTPHandler")
return &CollectionSyncHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*CollectionSyncHTTPHandler) Pattern() string {
return "POST /api/v1/collections/sync"
}
func (h *CollectionSyncHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *CollectionSyncHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Get user ID from context
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
if !ok {
h.logger.Error("Failed getting user ID from context")
httperror.RespondWithError(w, r, httperror.NewForInternalServerErrorWithSingleField("message", "Authentication context error"))
return
}
// Parse query parameters
queryParams := r.URL.Query()
// Parse limit parameter (default: 1000, max: 5000)
limit := int64(1000)
if limitStr := queryParams.Get("limit"); limitStr != "" {
if parsedLimit, err := strconv.ParseInt(limitStr, 10, 64); err == nil {
if parsedLimit > 0 && parsedLimit <= 5000 {
limit = parsedLimit
} else {
h.logger.Warn("Invalid limit parameter, using default",
zap.String("limit", limitStr),
zap.Int64("default", limit))
}
} else {
h.logger.Warn("Failed to parse limit parameter, using default",
zap.String("limit", limitStr),
zap.Error(err))
}
}
// Parse cursor parameter
var cursor *dom_sync.CollectionSyncCursor
if cursorStr := queryParams.Get("cursor"); cursorStr != "" {
var parsedCursor dom_sync.CollectionSyncCursor
if err := json.Unmarshal([]byte(cursorStr), &parsedCursor); err != nil {
h.logger.Error("Failed to parse cursor parameter",
zap.String("cursor", cursorStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("cursor", "Invalid cursor format"))
return
}
cursor = &parsedCursor
}
h.logger.Debug("Processing collection sync request",
zap.Any("user_id", userID),
zap.Int64("limit", limit),
zap.Any("cursor", cursor))
// Call service to get sync data
response, err := h.service.Execute(ctx, userID, cursor, limit, "all")
if err != nil {
h.logger.Error("Failed to get collection sync data",
zap.Any("user_id", userID),
zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
// Encode and return response
if err := json.NewEncoder(w).Encode(response); err != nil {
h.logger.Error("Failed to encode collection sync response",
zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
h.logger.Info("Successfully served collection sync data",
zap.Any("user_id", userID),
zap.Int("collections_count", len(response.Collections)),
zap.Bool("has_more", response.HasMore))
}

View file

@ -0,0 +1,136 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/collection/update.go
package collection
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type UpdateCollectionHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_collection.UpdateCollectionService
middleware middleware.Middleware
}
func NewUpdateCollectionHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_collection.UpdateCollectionService,
middleware middleware.Middleware,
) *UpdateCollectionHTTPHandler {
logger = logger.Named("UpdateCollectionHTTPHandler")
return &UpdateCollectionHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*UpdateCollectionHTTPHandler) Pattern() string {
return "PUT /api/v1/collections/{id}"
}
func (h *UpdateCollectionHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *UpdateCollectionHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
collectionID gocql.UUID,
) (*svc_collection.UpdateCollectionRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var requestData svc_collection.UpdateCollectionRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
// Set the collection ID from the URL parameter
requestData.ID = collectionID
return &requestData, nil
}
func (h *UpdateCollectionHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract collection ID from the URL path parameter
// This assumes the router is net/http (Go 1.22+) and the pattern was registered like "PUT /path/{id}"
collectionIDStr := r.PathValue("id")
if collectionIDStr == "" {
h.logger.Warn("collection_id not found in path parameters or is empty",
zap.String("path", r.URL.Path),
zap.String("method", r.Method),
)
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Collection ID is required"))
return
}
// Convert string ID to ObjectID
collectionID, err := gocql.ParseUUID(collectionIDStr)
if err != nil {
h.logger.Error("invalid collection ID format",
zap.String("collection_id", collectionIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Invalid collection ID format"))
return
}
req, err := h.unmarshalRequest(ctx, r, collectionID)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Call service
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("transaction completed with no result") // Clarified error message
h.logger.Error("transaction completed with no result", zap.Any("request_payload", req))
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,13 @@
package common
import (
"go.uber.org/zap"
)
// Wire providers for common HTTP handlers
func ProvideMapleFileVersionHTTPHandler(
logger *zap.Logger,
) *MapleFileVersionHTTPHandler {
return NewMapleFileVersionHTTPHandler(logger)
}

View file

@ -0,0 +1,34 @@
package common
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
)
// curl http://localhost:8000/maplefile/api/v1/version
type MapleFileVersionHTTPHandler struct {
log *zap.Logger
}
func NewMapleFileVersionHTTPHandler(
log *zap.Logger,
) *MapleFileVersionHTTPHandler {
log = log.Named("MapleFileVersionHTTPHandler")
return &MapleFileVersionHTTPHandler{log}
}
type MapleFileVersionResponseIDO struct {
Version string `json:"version"`
}
func (h *MapleFileVersionHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
response := MapleFileVersionResponseIDO{Version: "v1.0.0"}
json.NewEncoder(w).Encode(response)
}
func (*MapleFileVersionHTTPHandler) Pattern() string {
return "/maplefile/api/v1/version"
}

View file

@ -0,0 +1,85 @@
// cloud/maplefile-backend/internal/maplefile/interface/http/dashboard/get.go
package dashboard
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_dashboard "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/dashboard"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetDashboardHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_dashboard.GetDashboardService
middleware middleware.Middleware
}
func NewGetDashboardHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_dashboard.GetDashboardService,
middleware middleware.Middleware,
) *GetDashboardHTTPHandler {
logger = logger.With(zap.String("module", "maplefile"))
logger = logger.Named("GetDashboardHTTPHandler")
return &GetDashboardHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*GetDashboardHTTPHandler) Pattern() string {
return "GET /api/v1/dashboard"
}
func (h *GetDashboardHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *GetDashboardHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
//
// STEP 1: Execute service
//
resp, err := h.service.Execute(ctx)
if err != nil {
h.logger.Error("Failed to get dashboard data",
zap.Error(err))
// Service returns RFC 9457 errors, use RespondWithError to handle them
httperror.RespondWithError(w, r, err)
return
}
//
// STEP 2: Encode and return response
//
if resp == nil {
h.logger.Error("No dashboard data returned from service")
problem := httperror.NewInternalServerError("Failed to retrieve dashboard data. Please try again.").
WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("Failed to encode dashboard response",
zap.Error(err))
// At this point headers are already sent, log the error but can't send RFC 9457 response
return
}
h.logger.Debug("Dashboard data successfully returned")
}

View file

@ -0,0 +1,20 @@
package dashboard
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_dashboard "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/dashboard"
)
// Wire provider for dashboard HTTP handlers
func ProvideGetDashboardHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_dashboard.GetDashboardService,
mw middleware.Middleware,
) *GetDashboardHTTPHandler {
return NewGetDashboardHTTPHandler(cfg, logger, service, mw)
}

View file

@ -0,0 +1,97 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/archive.go
package file
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ArchiveFileHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.ArchiveFileService
middleware middleware.Middleware
}
func NewArchiveFileHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.ArchiveFileService,
middleware middleware.Middleware,
) *ArchiveFileHTTPHandler {
logger = logger.Named("ArchiveFileHTTPHandler")
return &ArchiveFileHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*ArchiveFileHTTPHandler) Pattern() string {
return "PUT /api/v1/file/{id}/archive"
}
func (h *ArchiveFileHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *ArchiveFileHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract file ID from the URL
fileIDStr := r.PathValue("id")
if fileIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required"))
return
}
// Convert string ID to ObjectID
fileID, err := gocql.ParseUUID(fileIDStr)
if err != nil {
h.logger.Error("invalid file ID format",
zap.String("file_id", fileIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "Invalid file ID format"))
return
}
// Create request DTO
dtoReq := &svc_file.ArchiveFileRequestDTO{
FileID: fileID,
}
resp, err := h.service.Execute(ctx, dtoReq)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.String("file_id", fileIDStr),
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,129 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/complete_file_upload.go
package file
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type CompleteFileUploadHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.CompleteFileUploadService
middleware middleware.Middleware
}
func NewCompleteFileUploadHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.CompleteFileUploadService,
middleware middleware.Middleware,
) *CompleteFileUploadHTTPHandler {
logger = logger.Named("CompleteFileUploadHTTPHandler")
return &CompleteFileUploadHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*CompleteFileUploadHTTPHandler) Pattern() string {
return "POST /api/v1/file/{id}/complete"
}
func (h *CompleteFileUploadHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *CompleteFileUploadHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
fileID gocql.UUID,
) (*svc_file.CompleteFileUploadRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var requestData svc_file.CompleteFileUploadRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
// Set the file ID from the URL parameter
requestData.FileID = fileID
return &requestData, nil
}
func (h *CompleteFileUploadHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract file ID from URL parameters
fileIDStr := r.PathValue("id")
if fileIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required"))
return
}
// Convert string ID to ObjectID
fileID, err := gocql.ParseUUID(fileIDStr)
if err != nil {
h.logger.Error("invalid file ID format",
zap.String("file_id", fileIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "Invalid file ID format"))
return
}
req, err := h.unmarshalRequest(ctx, r, fileID)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,108 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/create_pending_file.go
package file
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type CreatePendingFileHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.CreatePendingFileService
middleware middleware.Middleware
}
func NewCreatePendingFileHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.CreatePendingFileService,
middleware middleware.Middleware,
) *CreatePendingFileHTTPHandler {
logger = logger.Named("CreatePendingFileHTTPHandler")
return &CreatePendingFileHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*CreatePendingFileHTTPHandler) Pattern() string {
return "POST /api/v1/files/pending"
}
func (h *CreatePendingFileHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *CreatePendingFileHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
) (*svc_file.CreatePendingFileRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var requestData svc_file.CreatePendingFileRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err))
// Log raw JSON at debug level only to avoid PII exposure in production logs
h.logger.Debug("raw request body for debugging",
zap.String("json", rawJSON.String()))
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
return &requestData, nil
}
func (h *CreatePendingFileHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
req, err := h.unmarshalRequest(ctx, r)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,91 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/get.go
package file
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetFileHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.GetFileService
middleware middleware.Middleware
}
func NewGetFileHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.GetFileService,
middleware middleware.Middleware,
) *GetFileHTTPHandler {
logger = logger.Named("GetFileHTTPHandler")
return &GetFileHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*GetFileHTTPHandler) Pattern() string {
return "GET /api/v1/file/{id}"
}
func (h *GetFileHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *GetFileHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract file ID from URL parameters
fileIDStr := r.PathValue("id")
if fileIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required"))
return
}
// Convert string ID to ObjectID
fileID, err := gocql.ParseUUID(fileIDStr)
if err != nil {
h.logger.Error("invalid file ID format",
zap.String("file_id", fileIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "Invalid file ID format"))
return
}
resp, err := h.service.Execute(ctx, fileID)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,134 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/get_presigned_download_url.go
package file
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetPresignedDownloadURLHTTPRequestDTO struct {
URLDurationStr string `json:"url_duration,omitempty"` // Optional, duration as string of nanoseconds, defaults to 1 hour
}
type GetPresignedDownloadURLHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.GetPresignedDownloadURLService
middleware middleware.Middleware
}
func NewGetPresignedDownloadURLHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.GetPresignedDownloadURLService,
middleware middleware.Middleware,
) *GetPresignedDownloadURLHTTPHandler {
logger = logger.Named("GetPresignedDownloadURLHTTPHandler")
return &GetPresignedDownloadURLHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*GetPresignedDownloadURLHTTPHandler) Pattern() string {
return "GET /api/v1/file/{id}/download-url"
}
func (h *GetPresignedDownloadURLHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *GetPresignedDownloadURLHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
fileID gocql.UUID,
) (*svc_file.GetPresignedDownloadURLRequestDTO, error) {
// For GET requests, read from query parameters instead of body
urlDurationStr := r.URL.Query().Get("url_duration")
// Set default URL duration if not provided (1 hour in nanoseconds)
var urlDuration time.Duration
if urlDurationStr == "" {
urlDuration = 1 * time.Hour
} else {
// Parse the string to int64 (nanoseconds)
durationNanos, err := strconv.ParseInt(urlDurationStr, 10, 64)
if err != nil {
return nil, httperror.NewForSingleField(http.StatusBadRequest, "url_duration", "Invalid duration format")
}
urlDuration = time.Duration(durationNanos)
}
// Convert to service DTO
serviceRequest := &svc_file.GetPresignedDownloadURLRequestDTO{
FileID: fileID,
URLDuration: urlDuration,
}
return serviceRequest, nil
}
func (h *GetPresignedDownloadURLHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract file ID from URL parameters
fileIDStr := r.PathValue("id")
if fileIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required"))
return
}
// Convert string ID to ObjectID
fileID, err := gocql.ParseUUID(fileIDStr)
if err != nil {
h.logger.Error("invalid file ID format",
zap.String("file_id", fileIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "Invalid file ID format"))
return
}
req, err := h.unmarshalRequest(ctx, r, fileID)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,152 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/get_presigned_upload_url.go
package file
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"time"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetPresignedUploadURLHTTPRequestDTO struct {
URLDurationStr string `json:"url_duration,omitempty"` // Optional, duration as string of nanoseconds, defaults to 1 hour
}
type GetPresignedUploadURLHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.GetPresignedUploadURLService
middleware middleware.Middleware
}
func NewGetPresignedUploadURLHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.GetPresignedUploadURLService,
middleware middleware.Middleware,
) *GetPresignedUploadURLHTTPHandler {
logger = logger.Named("GetPresignedUploadURLHTTPHandler")
return &GetPresignedUploadURLHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*GetPresignedUploadURLHTTPHandler) Pattern() string {
return "GET /api/v1/file/{id}/upload-url"
}
func (h *GetPresignedUploadURLHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *GetPresignedUploadURLHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
fileID gocql.UUID,
) (*svc_file.GetPresignedUploadURLRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var httpRequestData GetPresignedUploadURLHTTPRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct
err := json.NewDecoder(teeReader).Decode(&httpRequestData)
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
// Set default URL duration if not provided (1 hour in nanoseconds)
var urlDuration time.Duration
if httpRequestData.URLDurationStr == "" {
urlDuration = 1 * time.Hour
} else {
// Parse the string to int64 (nanoseconds)
durationNanos, err := strconv.ParseInt(httpRequestData.URLDurationStr, 10, 64)
if err != nil {
return nil, httperror.NewForSingleField(http.StatusBadRequest, "url_duration", "Invalid duration format")
}
urlDuration = time.Duration(durationNanos)
}
// Convert to service DTO
serviceRequest := &svc_file.GetPresignedUploadURLRequestDTO{
FileID: fileID,
URLDuration: urlDuration,
}
return serviceRequest, nil
}
func (h *GetPresignedUploadURLHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract file ID from URL parameters
fileIDStr := r.PathValue("id")
if fileIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required"))
return
}
// Convert string ID to ObjectID
fileID, err := gocql.ParseUUID(fileIDStr)
if err != nil {
h.logger.Error("invalid file ID format",
zap.String("file_id", fileIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "Invalid file ID format"))
return
}
req, err := h.unmarshalRequest(ctx, r, fileID)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Call service
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,96 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/list_by_collection.go
package file
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ListFilesByCollectionHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.ListFilesByCollectionService
middleware middleware.Middleware
}
func NewListFilesByCollectionHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.ListFilesByCollectionService,
middleware middleware.Middleware,
) *ListFilesByCollectionHTTPHandler {
logger = logger.Named("ListFilesByCollectionHTTPHandler")
return &ListFilesByCollectionHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*ListFilesByCollectionHTTPHandler) Pattern() string {
return "GET /api/v1/collection/{collection_id}/files"
}
func (h *ListFilesByCollectionHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *ListFilesByCollectionHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract collection ID from URL parameters
collectionIDStr := r.PathValue("collection_id")
if collectionIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Collection ID is required"))
return
}
// Convert string ID to ObjectID
collectionID, err := gocql.ParseUUID(collectionIDStr)
if err != nil {
h.logger.Error("invalid collection ID format",
zap.String("collection_id", collectionIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("collection_id", "Invalid collection ID format"))
return
}
// Create request DTO
req := &svc_file.ListFilesByCollectionRequestDTO{
CollectionID: collectionID,
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,106 @@
// cloud/maplefile-backend/internal/maplefile/interface/http/file/list_recent_files.go
package file
import (
"encoding/json"
"net/http"
"strconv"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
file_service "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ListRecentFilesHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
listRecentFilesService file_service.ListRecentFilesService
middleware middleware.Middleware
}
func NewListRecentFilesHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
listRecentFilesService file_service.ListRecentFilesService,
middleware middleware.Middleware,
) *ListRecentFilesHTTPHandler {
logger = logger.Named("ListRecentFilesHTTPHandler")
return &ListRecentFilesHTTPHandler{
config: config,
logger: logger,
listRecentFilesService: listRecentFilesService,
middleware: middleware,
}
}
func (*ListRecentFilesHTTPHandler) Pattern() string {
return "GET /api/v1/files/recent"
}
func (h *ListRecentFilesHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *ListRecentFilesHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Parse query parameters
queryParams := r.URL.Query()
// Parse limit parameter (default: 30, max: 100)
limit := int64(30)
if limitStr := queryParams.Get("limit"); limitStr != "" {
if parsedLimit, err := strconv.ParseInt(limitStr, 10, 64); err == nil {
if parsedLimit > 0 && parsedLimit <= 100 {
limit = parsedLimit
} else {
h.logger.Warn("Invalid limit parameter, using default",
zap.String("limit", limitStr),
zap.Int64("default", limit))
}
} else {
h.logger.Warn("Failed to parse limit parameter, using default",
zap.String("limit", limitStr),
zap.Error(err))
}
}
// Parse cursor parameter
var cursor *string
if cursorStr := queryParams.Get("cursor"); cursorStr != "" {
cursor = &cursorStr
}
h.logger.Debug("Processing recent files request",
zap.Int64("limit", limit),
zap.Any("cursor", cursor))
// Call service to get recent files
response, err := h.listRecentFilesService.Execute(ctx, cursor, limit)
if err != nil {
h.logger.Error("Failed to get recent files",
zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
// Encode and return response
if err := json.NewEncoder(w).Encode(response); err != nil {
h.logger.Error("Failed to encode recent files response",
zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
h.logger.Info("Successfully served recent files",
zap.Int("files_count", len(response.Files)),
zap.Bool("has_more", response.HasMore),
zap.Any("next_cursor", response.NextCursor))
}

View file

@ -0,0 +1,146 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/list_sync.go
package file
import (
"encoding/json"
"net/http"
"strconv"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
file_service "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type FileSyncHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
fileSyncService file_service.ListFileSyncDataService
middleware middleware.Middleware
}
func NewFileSyncHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
fileSyncService file_service.ListFileSyncDataService,
middleware middleware.Middleware,
) *FileSyncHTTPHandler {
logger = logger.Named("FileSyncHTTPHandler")
return &FileSyncHTTPHandler{
config: config,
logger: logger,
fileSyncService: fileSyncService,
middleware: middleware,
}
}
func (*FileSyncHTTPHandler) Pattern() string {
return "POST /api/v1/files/sync"
}
func (h *FileSyncHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *FileSyncHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Get user ID from context
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
if !ok {
h.logger.Error("Failed getting user ID from context")
httperror.RespondWithError(w, r, httperror.NewForInternalServerErrorWithSingleField("message", "Authentication context error"))
return
}
// Parse query parameters
queryParams := r.URL.Query()
// Parse limit parameter (default: 5000, max: 10000)
limit := int64(5000)
if limitStr := queryParams.Get("limit"); limitStr != "" {
if parsedLimit, err := strconv.ParseInt(limitStr, 10, 64); err == nil {
if parsedLimit > 0 && parsedLimit <= 10000 {
limit = parsedLimit
} else {
h.logger.Warn("Invalid limit parameter, using default",
zap.String("limit", limitStr),
zap.Int64("default", limit))
}
} else {
h.logger.Warn("Failed to parse limit parameter, using default",
zap.String("limit", limitStr),
zap.Error(err))
}
}
// Parse cursor parameter
var cursor *dom_file.FileSyncCursor
if cursorStr := queryParams.Get("cursor"); cursorStr != "" {
var parsedCursor dom_file.FileSyncCursor
if err := json.Unmarshal([]byte(cursorStr), &parsedCursor); err != nil {
h.logger.Error("Failed to parse cursor parameter",
zap.String("cursor", cursorStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("cursor", "Invalid cursor format"))
return
}
cursor = &parsedCursor
}
h.logger.Debug("Processing file sync request",
zap.Any("user_id", userID),
zap.Int64("limit", limit),
zap.Any("cursor", cursor))
// Call service to get sync data
response, err := h.fileSyncService.Execute(ctx, cursor, limit)
if err != nil {
h.logger.Error("Failed to get file sync data",
zap.Any("user_id", userID),
zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
// Verify the response contains all fields including EncryptedFileSizeInBytes before encoding
h.logger.Debug("File sync response validation",
zap.Any("user_id", userID),
zap.Int("files_count", len(response.Files)))
for i, item := range response.Files {
h.logger.Debug("File sync response item",
zap.Int("index", i),
zap.String("file_id", item.ID.String()),
zap.String("collection_id", item.CollectionID.String()),
zap.Uint64("version", item.Version),
zap.Time("modified_at", item.ModifiedAt),
zap.String("state", item.State),
zap.Uint64("tombstone_version", item.TombstoneVersion),
zap.Time("tombstone_expiry", item.TombstoneExpiry),
zap.Int64("encrypted_file_size_in_bytes", item.EncryptedFileSizeInBytes))
}
// Encode and return response
if err := json.NewEncoder(w).Encode(response); err != nil {
h.logger.Error("Failed to encode file sync response",
zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
h.logger.Info("Successfully served file sync data",
zap.Any("user_id", userID),
zap.Int("files_count", len(response.Files)),
zap.Bool("has_more", response.HasMore),
zap.Any("next_cursor", response.NextCursor))
}

View file

@ -0,0 +1,136 @@
package file
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
)
// Wire providers for file HTTP handlers
func ProvideCreatePendingFileHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.CreatePendingFileService,
mw middleware.Middleware,
) *CreatePendingFileHTTPHandler {
return NewCreatePendingFileHTTPHandler(cfg, logger, service, mw)
}
func ProvideGetPresignedUploadURLHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.GetPresignedUploadURLService,
mw middleware.Middleware,
) *GetPresignedUploadURLHTTPHandler {
return NewGetPresignedUploadURLHTTPHandler(cfg, logger, service, mw)
}
func ProvideCompleteFileUploadHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.CompleteFileUploadService,
mw middleware.Middleware,
) *CompleteFileUploadHTTPHandler {
return NewCompleteFileUploadHTTPHandler(cfg, logger, service, mw)
}
func ProvideGetFileHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.GetFileService,
mw middleware.Middleware,
) *GetFileHTTPHandler {
return NewGetFileHTTPHandler(cfg, logger, service, mw)
}
func ProvideGetPresignedDownloadURLHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.GetPresignedDownloadURLService,
mw middleware.Middleware,
) *GetPresignedDownloadURLHTTPHandler {
return NewGetPresignedDownloadURLHTTPHandler(cfg, logger, service, mw)
}
func ProvideListFilesByCollectionHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.ListFilesByCollectionService,
mw middleware.Middleware,
) *ListFilesByCollectionHTTPHandler {
return NewListFilesByCollectionHTTPHandler(cfg, logger, service, mw)
}
func ProvideListRecentFilesHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.ListRecentFilesService,
mw middleware.Middleware,
) *ListRecentFilesHTTPHandler {
return NewListRecentFilesHTTPHandler(cfg, logger, service, mw)
}
func ProvideUpdateFileHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.UpdateFileService,
mw middleware.Middleware,
) *UpdateFileHTTPHandler {
return NewUpdateFileHTTPHandler(cfg, logger, service, mw)
}
func ProvideSoftDeleteFileHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.SoftDeleteFileService,
mw middleware.Middleware,
) *SoftDeleteFileHTTPHandler {
return NewSoftDeleteFileHTTPHandler(cfg, logger, service, mw)
}
func ProvideArchiveFileHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.ArchiveFileService,
mw middleware.Middleware,
) *ArchiveFileHTTPHandler {
return NewArchiveFileHTTPHandler(cfg, logger, service, mw)
}
func ProvideRestoreFileHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.RestoreFileService,
mw middleware.Middleware,
) *RestoreFileHTTPHandler {
return NewRestoreFileHTTPHandler(cfg, logger, service, mw)
}
func ProvideDeleteMultipleFilesHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.DeleteMultipleFilesService,
mw middleware.Middleware,
) *DeleteMultipleFilesHTTPHandler {
return NewDeleteMultipleFilesHTTPHandler(cfg, logger, service, mw)
}
func ProvideFileSyncHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_file.ListFileSyncDataService,
mw middleware.Middleware,
) *FileSyncHTTPHandler {
return NewFileSyncHTTPHandler(cfg, logger, service, mw)
}
func ProvideReportDownloadCompletedHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
mw middleware.Middleware,
) *ReportDownloadCompletedHTTPHandler {
return NewReportDownloadCompletedHTTPHandler(cfg, logger, mw)
}

View file

@ -0,0 +1,82 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/report_download_completed.go
package file
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ReportDownloadCompletedHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
middleware middleware.Middleware
}
func NewReportDownloadCompletedHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
middleware middleware.Middleware,
) *ReportDownloadCompletedHTTPHandler {
logger = logger.Named("ReportDownloadCompletedHTTPHandler")
return &ReportDownloadCompletedHTTPHandler{
config: config,
logger: logger,
middleware: middleware,
}
}
func (*ReportDownloadCompletedHTTPHandler) Pattern() string {
return "POST /api/v1/file/{id}/download-completed"
}
func (h *ReportDownloadCompletedHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *ReportDownloadCompletedHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
// Extract file ID from the URL
fileIDStr := r.PathValue("id")
if fileIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required"))
return
}
// Validate UUID format
_, err := gocql.ParseUUID(fileIDStr)
if err != nil {
h.logger.Error("invalid file ID format",
zap.String("file_id", fileIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "Invalid file ID format"))
return
}
// Log the download completion (analytics/telemetry)
h.logger.Debug("download completed reported",
zap.String("file_id", fileIDStr))
// Return success response
response := map[string]interface{}{
"success": true,
"message": "Download completion recorded",
}
if err := json.NewEncoder(w).Encode(response); err != nil {
h.logger.Error("failed to encode response",
zap.String("file_id", fileIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,97 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/restore.go
package file
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type RestoreFileHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.RestoreFileService
middleware middleware.Middleware
}
func NewRestoreFileHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.RestoreFileService,
middleware middleware.Middleware,
) *RestoreFileHTTPHandler {
logger = logger.Named("RestoreFileHTTPHandler")
return &RestoreFileHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*RestoreFileHTTPHandler) Pattern() string {
return "PUT /api/v1/file/{id}/restore"
}
func (h *RestoreFileHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *RestoreFileHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract file ID from the URL
fileIDStr := r.PathValue("id")
if fileIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required"))
return
}
// Convert string ID to ObjectID
fileID, err := gocql.ParseUUID(fileIDStr)
if err != nil {
h.logger.Error("invalid file ID format",
zap.String("file_id", fileIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "Invalid file ID format"))
return
}
// Create request DTO
dtoReq := &svc_file.RestoreFileRequestDTO{
FileID: fileID,
}
resp, err := h.service.Execute(ctx, dtoReq)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.String("file_id", fileIDStr),
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,97 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/softdelete.go
package file
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type SoftDeleteFileHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.SoftDeleteFileService
middleware middleware.Middleware
}
func NewSoftDeleteFileHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.SoftDeleteFileService,
middleware middleware.Middleware,
) *SoftDeleteFileHTTPHandler {
logger = logger.Named("SoftDeleteFileHTTPHandler")
return &SoftDeleteFileHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*SoftDeleteFileHTTPHandler) Pattern() string {
return "DELETE /api/v1/file/{id}"
}
func (h *SoftDeleteFileHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *SoftDeleteFileHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract file ID from the URL
fileIDStr := r.PathValue("id")
if fileIDStr == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required"))
return
}
// Convert string ID to ObjectID
fileID, err := gocql.ParseUUID(fileIDStr)
if err != nil {
h.logger.Error("invalid file ID format",
zap.String("file_id", fileIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "Invalid file ID format"))
return
}
// Create request DTO
dtoReq := &svc_file.SoftDeleteFileRequestDTO{
FileID: fileID,
}
resp, err := h.service.Execute(ctx, dtoReq)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.String("file_id", fileIDStr),
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,107 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/delete_multiple.go
package file
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type DeleteMultipleFilesHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.DeleteMultipleFilesService
middleware middleware.Middleware
}
func NewDeleteMultipleFilesHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.DeleteMultipleFilesService,
middleware middleware.Middleware,
) *DeleteMultipleFilesHTTPHandler {
logger = logger.Named("DeleteMultipleFilesHTTPHandler")
return &DeleteMultipleFilesHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*DeleteMultipleFilesHTTPHandler) Pattern() string {
return "POST /api/v1/files/delete-multiple"
}
func (h *DeleteMultipleFilesHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *DeleteMultipleFilesHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
) (*svc_file.DeleteMultipleFilesRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var requestData svc_file.DeleteMultipleFilesRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
return &requestData, nil
}
func (h *DeleteMultipleFilesHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
req, err := h.unmarshalRequest(ctx, r)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,135 @@
// monorepo/cloud/backend/internal/maplefile/interface/http/file/update.go
package file
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type UpdateFileHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_file.UpdateFileService
middleware middleware.Middleware
}
func NewUpdateFileHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_file.UpdateFileService,
middleware middleware.Middleware,
) *UpdateFileHTTPHandler {
logger = logger.Named("UpdateFileHTTPHandler")
return &UpdateFileHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*UpdateFileHTTPHandler) Pattern() string {
return "PUT /api/v1/file/{id}"
}
func (h *UpdateFileHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *UpdateFileHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
fileID gocql.UUID,
) (*svc_file.UpdateFileRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var requestData svc_file.UpdateFileRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err))
// Log raw JSON at debug level only to avoid PII exposure in production logs
h.logger.Debug("raw request body for debugging",
zap.String("json", rawJSON.String()))
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
// Set the file ID from the URL parameter
requestData.ID = fileID
return &requestData, nil
}
func (h *UpdateFileHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
// Extract file ID from the URL path parameter
fileIDStr := r.PathValue("id")
if fileIDStr == "" {
h.logger.Warn("file_id not found in path parameters or is empty",
zap.String("path", r.URL.Path),
zap.String("method", r.Method),
)
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required"))
return
}
// Convert string ID to ObjectID
fileID, err := gocql.ParseUUID(fileIDStr)
if err != nil {
h.logger.Error("invalid file ID format",
zap.String("file_id", fileIDStr),
zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("file_id", "Invalid file ID format"))
return
}
req, err := h.unmarshalRequest(ctx, r, fileID)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("transaction completed with no result")
h.logger.Error("transaction completed with no result", zap.Any("request_payload", req))
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,258 @@
package http
import (
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/blockedemail"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/collection"
commonhttp "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/common"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/dashboard"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/inviteemail"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/me"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/user"
)
// Handlers aggregates all HTTP handlers
type Handlers struct {
// Common handlers
Version *commonhttp.MapleFileVersionHTTPHandler
// Dashboard handlers
GetDashboard *dashboard.GetDashboardHTTPHandler
// Me handlers
GetMe *me.GetMeHTTPHandler
UpdateMe *me.PutUpdateMeHTTPHandler
DeleteMe *me.DeleteMeHTTPHandler
// User handlers
UserPublicLookup *user.UserPublicLookupHTTPHandler
// Blocked Email handlers
CreateBlockedEmail *blockedemail.CreateBlockedEmailHTTPHandler
ListBlockedEmails *blockedemail.ListBlockedEmailsHTTPHandler
DeleteBlockedEmail *blockedemail.DeleteBlockedEmailHTTPHandler
// Invite Email handlers
SendInviteEmail *inviteemail.SendInviteEmailHTTPHandler
// Collection handlers - Basic CRUD
CreateCollection *collection.CreateCollectionHTTPHandler
GetCollection *collection.GetCollectionHTTPHandler
ListUserCollections *collection.ListUserCollectionsHTTPHandler
UpdateCollection *collection.UpdateCollectionHTTPHandler
SoftDeleteCollection *collection.SoftDeleteCollectionHTTPHandler
ArchiveCollection *collection.ArchiveCollectionHTTPHandler
RestoreCollection *collection.RestoreCollectionHTTPHandler
// Collection handlers - Hierarchical operations
FindCollectionsByParent *collection.FindCollectionsByParentHTTPHandler
FindRootCollections *collection.FindRootCollectionsHTTPHandler
MoveCollection *collection.MoveCollectionHTTPHandler
// Collection handlers - Sharing
ShareCollection *collection.ShareCollectionHTTPHandler
RemoveMember *collection.RemoveMemberHTTPHandler
ListSharedCollections *collection.ListSharedCollectionsHTTPHandler
// Collection handlers - Filtered operations
GetFilteredCollections *collection.GetFilteredCollectionsHTTPHandler
// Collection Sync
CollectionSync *collection.CollectionSyncHTTPHandler
// File handlers - Basic CRUD
SoftDeleteFile *file.SoftDeleteFileHTTPHandler
DeleteMultipleFiles *file.DeleteMultipleFilesHTTPHandler
GetFile *file.GetFileHTTPHandler
ListFilesByCollection *file.ListFilesByCollectionHTTPHandler
UpdateFile *file.UpdateFileHTTPHandler
CreatePendingFile *file.CreatePendingFileHTTPHandler
CompleteFileUpload *file.CompleteFileUploadHTTPHandler
GetPresignedUploadURL *file.GetPresignedUploadURLHTTPHandler
GetPresignedDownloadURL *file.GetPresignedDownloadURLHTTPHandler
ReportDownloadCompleted *file.ReportDownloadCompletedHTTPHandler
ArchiveFile *file.ArchiveFileHTTPHandler
RestoreFile *file.RestoreFileHTTPHandler
ListRecentFiles *file.ListRecentFilesHTTPHandler
// File Sync
FileSync *file.FileSyncHTTPHandler
// Tag handlers
CreateTag *tag.CreateTagHTTPHandler
ListTags *tag.ListTagsHTTPHandler
GetTag *tag.GetTagHTTPHandler
UpdateTag *tag.UpdateTagHTTPHandler
DeleteTag *tag.DeleteTagHTTPHandler
AssignTag *tag.AssignTagHTTPHandler
UnassignTag *tag.UnassignTagHTTPHandler
GetTagsForCollection *tag.GetTagsForCollectionHTTPHandler
GetTagsForFile *tag.GetTagsForFileHTTPHandler
ListCollectionsByTag *tag.ListCollectionsByTagHandler
ListFilesByTag *tag.ListFilesByTagHandler
SearchByTags *tag.SearchByTagsHandler
}
// NewHandlers creates and wires all HTTP handlers
func NewHandlers(
// Common
versionHandler *commonhttp.MapleFileVersionHTTPHandler,
// Dashboard
getDashboard *dashboard.GetDashboardHTTPHandler,
// Me
getMe *me.GetMeHTTPHandler,
updateMe *me.PutUpdateMeHTTPHandler,
deleteMe *me.DeleteMeHTTPHandler,
// User
userPublicLookup *user.UserPublicLookupHTTPHandler,
// Blocked Email
createBlockedEmail *blockedemail.CreateBlockedEmailHTTPHandler,
listBlockedEmails *blockedemail.ListBlockedEmailsHTTPHandler,
deleteBlockedEmail *blockedemail.DeleteBlockedEmailHTTPHandler,
// Invite Email
sendInviteEmail *inviteemail.SendInviteEmailHTTPHandler,
// Collection - Basic CRUD
createCollection *collection.CreateCollectionHTTPHandler,
getCollection *collection.GetCollectionHTTPHandler,
listUserCollections *collection.ListUserCollectionsHTTPHandler,
updateCollection *collection.UpdateCollectionHTTPHandler,
softDeleteCollection *collection.SoftDeleteCollectionHTTPHandler,
archiveCollection *collection.ArchiveCollectionHTTPHandler,
restoreCollection *collection.RestoreCollectionHTTPHandler,
// Collection - Hierarchical
findCollectionsByParent *collection.FindCollectionsByParentHTTPHandler,
findRootCollections *collection.FindRootCollectionsHTTPHandler,
moveCollection *collection.MoveCollectionHTTPHandler,
// Collection - Sharing
shareCollection *collection.ShareCollectionHTTPHandler,
removeMember *collection.RemoveMemberHTTPHandler,
listSharedCollections *collection.ListSharedCollectionsHTTPHandler,
// Collection - Filtered
getFilteredCollections *collection.GetFilteredCollectionsHTTPHandler,
// Collection - Sync
collectionSync *collection.CollectionSyncHTTPHandler,
// File - CRUD
softDeleteFile *file.SoftDeleteFileHTTPHandler,
deleteMultipleFiles *file.DeleteMultipleFilesHTTPHandler,
getFile *file.GetFileHTTPHandler,
listFilesByCollection *file.ListFilesByCollectionHTTPHandler,
updateFile *file.UpdateFileHTTPHandler,
createPendingFile *file.CreatePendingFileHTTPHandler,
completeFileUpload *file.CompleteFileUploadHTTPHandler,
getPresignedUploadURL *file.GetPresignedUploadURLHTTPHandler,
getPresignedDownloadURL *file.GetPresignedDownloadURLHTTPHandler,
reportDownloadCompleted *file.ReportDownloadCompletedHTTPHandler,
archiveFile *file.ArchiveFileHTTPHandler,
restoreFile *file.RestoreFileHTTPHandler,
listRecentFiles *file.ListRecentFilesHTTPHandler,
// File - Sync
fileSync *file.FileSyncHTTPHandler,
// Tag handlers
createTag *tag.CreateTagHTTPHandler,
listTags *tag.ListTagsHTTPHandler,
getTag *tag.GetTagHTTPHandler,
updateTag *tag.UpdateTagHTTPHandler,
deleteTag *tag.DeleteTagHTTPHandler,
assignTag *tag.AssignTagHTTPHandler,
unassignTag *tag.UnassignTagHTTPHandler,
getTagsForCollection *tag.GetTagsForCollectionHTTPHandler,
getTagsForFile *tag.GetTagsForFileHTTPHandler,
listCollectionsByTag *tag.ListCollectionsByTagHandler,
listFilesByTag *tag.ListFilesByTagHandler,
searchByTags *tag.SearchByTagsHandler,
) *Handlers {
return &Handlers{
// Common
Version: versionHandler,
// Dashboard
GetDashboard: getDashboard,
// Me
GetMe: getMe,
UpdateMe: updateMe,
DeleteMe: deleteMe,
// User
UserPublicLookup: userPublicLookup,
// Blocked Email
CreateBlockedEmail: createBlockedEmail,
ListBlockedEmails: listBlockedEmails,
DeleteBlockedEmail: deleteBlockedEmail,
// Invite Email
SendInviteEmail: sendInviteEmail,
// Collection - Basic CRUD
CreateCollection: createCollection,
GetCollection: getCollection,
ListUserCollections: listUserCollections,
UpdateCollection: updateCollection,
SoftDeleteCollection: softDeleteCollection,
ArchiveCollection: archiveCollection,
RestoreCollection: restoreCollection,
// Collection - Hierarchical
FindCollectionsByParent: findCollectionsByParent,
FindRootCollections: findRootCollections,
MoveCollection: moveCollection,
// Collection - Sharing
ShareCollection: shareCollection,
RemoveMember: removeMember,
ListSharedCollections: listSharedCollections,
// Collection - Filtered
GetFilteredCollections: getFilteredCollections,
// Collection Sync
CollectionSync: collectionSync,
// File - CRUD
SoftDeleteFile: softDeleteFile,
DeleteMultipleFiles: deleteMultipleFiles,
GetFile: getFile,
ListFilesByCollection: listFilesByCollection,
UpdateFile: updateFile,
CreatePendingFile: createPendingFile,
CompleteFileUpload: completeFileUpload,
GetPresignedUploadURL: getPresignedUploadURL,
GetPresignedDownloadURL: getPresignedDownloadURL,
ReportDownloadCompleted: reportDownloadCompleted,
ArchiveFile: archiveFile,
RestoreFile: restoreFile,
ListRecentFiles: listRecentFiles,
// File Sync
FileSync: fileSync,
// Tag handlers
CreateTag: createTag,
ListTags: listTags,
GetTag: getTag,
UpdateTag: updateTag,
DeleteTag: deleteTag,
AssignTag: assignTag,
UnassignTag: unassignTag,
GetTagsForCollection: getTagsForCollection,
GetTagsForFile: getTagsForFile,
ListCollectionsByTag: listCollectionsByTag,
ListFilesByTag: listFilesByTag,
SearchByTags: searchByTags,
}
}

View file

@ -0,0 +1,19 @@
package inviteemail
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_inviteemail "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/inviteemail"
)
// ProvideSendInviteEmailHTTPHandler provides the send invite email HTTP handler for Wire DI
func ProvideSendInviteEmailHTTPHandler(
cfg *config.Config,
logger *zap.Logger,
service svc_inviteemail.SendInviteEmailService,
mw middleware.Middleware,
) *SendInviteEmailHTTPHandler {
return NewSendInviteEmailHTTPHandler(cfg, logger, service, mw)
}

View file

@ -0,0 +1,84 @@
// Package inviteemail provides HTTP handlers for invitation email endpoints
package inviteemail
import (
"encoding/json"
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_inviteemail "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/inviteemail"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
// SendInviteEmailHTTPHandler handles POST /api/v1/invites/send-email requests
type SendInviteEmailHTTPHandler struct {
config *config.Config
logger *zap.Logger
service svc_inviteemail.SendInviteEmailService
middleware middleware.Middleware
}
// NewSendInviteEmailHTTPHandler creates a new handler for sending invitation emails
func NewSendInviteEmailHTTPHandler(
cfg *config.Config,
logger *zap.Logger,
service svc_inviteemail.SendInviteEmailService,
mw middleware.Middleware,
) *SendInviteEmailHTTPHandler {
logger = logger.Named("SendInviteEmailHTTPHandler")
return &SendInviteEmailHTTPHandler{
config: cfg,
logger: logger,
service: service,
middleware: mw,
}
}
// Pattern returns the URL pattern for this handler
func (*SendInviteEmailHTTPHandler) Pattern() string {
return "POST /api/v1/invites/send-email"
}
// ServeHTTP implements http.Handler
func (h *SendInviteEmailHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware (authentication required)
h.middleware.Attach(h.Execute)(w, req)
}
// Execute handles the actual request processing
func (h *SendInviteEmailHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user ID from context (set by auth middleware)
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
if !ok {
h.logger.Error("User ID not found in context or invalid type")
httperror.RespondWithError(w, r, httperror.NewForUnauthorizedWithSingleField("auth", "Authentication required"))
return
}
// Decode request body
var req svc_inviteemail.SendInviteEmailRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Warn("Failed to decode request body", zap.Error(err))
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("body", "Invalid request body"))
return
}
// Execute service
response, err := h.service.Execute(ctx, userID, &req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Return response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}

View file

@ -0,0 +1,96 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/me/delete.go
package me
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_me "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type DeleteMeHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_me.DeleteMeService
middleware middleware.Middleware
}
func NewDeleteMeHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_me.DeleteMeService,
middleware middleware.Middleware,
) *DeleteMeHTTPHandler {
logger = logger.With(zap.String("module", "maplefile"))
logger = logger.Named("DeleteMeHTTPHandler")
return &DeleteMeHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*DeleteMeHTTPHandler) Pattern() string {
return "DELETE /api/v1/me"
}
func (r *DeleteMeHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply MaplesSend middleware before handling the request
r.middleware.Attach(r.Execute)(w, req)
}
func (h *DeleteMeHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
) (*svc_me.DeleteMeRequestDTO, error) {
// Initialize our structure which will store the parsed request data
var requestData svc_me.DeleteMeRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang struct else we need
// to send a `400 Bad Request` error message back to the client
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
return &requestData, nil
}
func (h *DeleteMeHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
req, err := h.unmarshalRequest(ctx, r)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
if err := h.service.Execute(ctx, req); err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Return successful no content response since the account was deleted
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,75 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/me/get.go
package me
import (
"encoding/json"
"errors"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_me "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetMeHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_me.GetMeService
middleware middleware.Middleware
}
func NewGetMeHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_me.GetMeService,
middleware middleware.Middleware,
) *GetMeHTTPHandler {
logger = logger.With(zap.String("module", "maplefile"))
logger = logger.Named("GetMeHTTPHandler")
return &GetMeHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*GetMeHTTPHandler) Pattern() string {
return "GET /api/v1/me"
}
func (r *GetMeHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply MaplesSend middleware before handling the request
r.middleware.Attach(r.Execute)(w, req)
}
func (h *GetMeHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
resp, err := h.service.Execute(ctx)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,38 @@
package me
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_me "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me"
)
// Wire providers for me HTTP handlers
func ProvideGetMeHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_me.GetMeService,
mw middleware.Middleware,
) *GetMeHTTPHandler {
return NewGetMeHTTPHandler(cfg, logger, service, mw)
}
func ProvidePutUpdateMeHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_me.UpdateMeService,
mw middleware.Middleware,
) *PutUpdateMeHTTPHandler {
return NewPutUpdateMeHTTPHandler(cfg, logger, service, mw)
}
func ProvideDeleteMeHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service svc_me.DeleteMeService,
mw middleware.Middleware,
) *DeleteMeHTTPHandler {
return NewDeleteMeHTTPHandler(cfg, logger, service, mw)
}

View file

@ -0,0 +1,110 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/me/get.go
package me
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_me "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type PutUpdateMeHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service svc_me.UpdateMeService
middleware middleware.Middleware
}
func NewPutUpdateMeHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service svc_me.UpdateMeService,
middleware middleware.Middleware,
) *PutUpdateMeHTTPHandler {
logger = logger.With(zap.String("module", "maplefile"))
logger = logger.Named("PutUpdateMeHTTPHandler")
return &PutUpdateMeHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*PutUpdateMeHTTPHandler) Pattern() string {
return "PUT /api/v1/me"
}
func (r *PutUpdateMeHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply MaplesSend middleware before handling the request
r.middleware.Attach(r.Execute)(w, req)
}
func (h *PutUpdateMeHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
) (*svc_me.UpdateMeRequestDTO, error) {
// Initialize our array which will store all the results from the remote server.
var requestData svc_me.UpdateMeRequestDTO
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it
// Read the JSON string and convert it into our golang stuct else we need
// to send a `400 Bad Request` errror message back to the client,
err := json.NewDecoder(teeReader).Decode(&requestData) // [1]
if err != nil {
h.logger.Error("decoding error",
zap.Any("err", err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong")
}
return &requestData, nil
}
func (h *PutUpdateMeHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
// Set response content type
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
req, err := h.unmarshalRequest(ctx, r)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
resp, err := h.service.Execute(ctx, req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
// Encode response
if resp != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
h.logger.Error("failed to encode response",
zap.Any("error", err))
httperror.RespondWithError(w, r, err)
return
}
} else {
err := errors.New("no result")
httperror.RespondWithError(w, r, err)
return
}
}

View file

@ -0,0 +1,74 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/interface/http/middleware/jwt.go
package middleware
import (
"context"
"net/http"
"strings"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
func (mid *middleware) JWTProcessorMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract the Authorization header
reqToken := r.Header.Get("Authorization")
// Validate that Authorization header is present
if reqToken == "" {
problem := httperror.NewUnauthorizedError("Authorization not set")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Extract the token from the Authorization header
// Support both "Bearer" (RFC 6750 standard) and "JWT" schemes for compatibility
var token string
if strings.HasPrefix(reqToken, "Bearer ") {
token = strings.TrimPrefix(reqToken, "Bearer ")
} else if strings.HasPrefix(reqToken, "JWT ") {
token = strings.TrimPrefix(reqToken, "JWT ")
} else {
problem := httperror.NewBadRequestError("Not properly formatted authorization header")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Validate the token is not empty after prefix removal
if token == "" {
problem := httperror.NewBadRequestError("Not properly formatted authorization header")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Process the JWT token
sessionID, err := mid.jwt.ProcessJWTToken(token)
if err != nil {
// Log the actual error for debugging but return generic message to client
mid.logger.Error("JWT processing failed", zap.Error(err))
problem := httperror.NewUnauthorizedError("Invalid or expired token")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Update our context to save our JWT token content information
ctx = context.WithValue(ctx, constants.SessionIsAuthorized, true)
ctx = context.WithValue(ctx, constants.SessionID, sessionID)
// Flow to the next middleware with our JWT token saved
fn(w, r.WithContext(ctx))
}
}

View file

@ -0,0 +1,95 @@
package middleware
import (
"context"
"net/http"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
func (mid *middleware) PostJWTProcessorMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get our authorization information.
isAuthorized, ok := ctx.Value(constants.SessionIsAuthorized).(bool)
if ok && isAuthorized {
// CWE-391: Safe type assertion to prevent panic-based DoS
// OWASP A09:2021: Security Logging and Monitoring - Prevents service crashes
sessionID, ok := ctx.Value(constants.SessionID).(string)
if !ok {
mid.logger.Error("Invalid session ID type in context")
problem := httperror.NewInternalServerError("Invalid session context")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Parse the user ID from the session ID (which is actually the user ID string from JWT)
userID, err := gocql.ParseUUID(sessionID)
if err != nil {
problem := httperror.NewUnauthorizedError("Invalid user ID in token")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Lookup our user profile by ID or return 500 error.
user, err := mid.userGetByIDUseCase.Execute(ctx, userID)
if err != nil {
// Log the actual error for debugging but return generic message to client
mid.logger.Error("Failed to get user by ID",
zap.Error(err),
zap.String("user_id", userID.String()))
problem := httperror.NewInternalServerError("Unable to verify session")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// If no user was found then that means our session expired and the
// user needs to login or use the refresh token.
if user == nil {
problem := httperror.NewUnauthorizedError("Session expired")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// // If system administrator disabled the user account then we need
// // to generate a 403 error letting the user know their account has
// // been disabled and you cannot access the protected API endpoint.
// if user.State == 0 {
// http.Error(w, "Account disabled - please contact admin", http.StatusForbidden)
// return
// }
// Save our user information to the context.
// Save our user.
ctx = context.WithValue(ctx, constants.SessionUser, user)
// Save individual pieces of the user profile.
ctx = context.WithValue(ctx, constants.SessionID, sessionID)
ctx = context.WithValue(ctx, constants.SessionUserID, user.ID)
ctx = context.WithValue(ctx, constants.SessionUserRole, user.Role)
ctx = context.WithValue(ctx, constants.SessionUserName, user.Name)
ctx = context.WithValue(ctx, constants.SessionUserFirstName, user.FirstName)
ctx = context.WithValue(ctx, constants.SessionUserLastName, user.LastName)
ctx = context.WithValue(ctx, constants.SessionUserTimezone, user.Timezone)
// ctx = context.WithValue(ctx, constants.SessionUserStoreID, user.StoreID)
// ctx = context.WithValue(ctx, constants.SessionUserStoreName, user.StoreName)
// ctx = context.WithValue(ctx, constants.SessionUserStoreLevel, user.StoreLevel)
// ctx = context.WithValue(ctx, constants.SessionUserStoreTimezone, user.StoreTimezone)
}
fn(w, r.WithContext(ctx))
}
}

View file

@ -0,0 +1,87 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware/middleware.go
package middleware
import (
"context"
"net/http"
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/jwt"
"go.uber.org/zap"
)
type Middleware interface {
Attach(fn http.HandlerFunc) http.HandlerFunc
Shutdown(ctx context.Context)
}
type middleware struct {
logger *zap.Logger
jwt jwt.JWTProvider
userGetByIDUseCase uc_user.UserGetByIDUseCase
}
func NewMiddleware(
logger *zap.Logger,
jwtp jwt.JWTProvider,
uc1 uc_user.UserGetByIDUseCase,
) Middleware {
logger = logger.With(zap.String("module", "maplefile"))
logger = logger.Named("MapleFile Middleware")
return &middleware{
logger: logger,
jwt: jwtp,
userGetByIDUseCase: uc1,
}
}
// Attach function attaches to HTTP router to apply for every API call.
func (mid *middleware) Attach(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Apply base middleware to all requests
handler := mid.applyBaseMiddleware(fn)
// Check if the path requires authentication
if isProtectedPath(mid.logger, r.URL.Path) {
// Apply auth middleware for protected paths
handler = mid.PostJWTProcessorMiddleware(handler)
handler = mid.JWTProcessorMiddleware(handler)
// handler = mid.EnforceBlacklistMiddleware(handler)
}
handler(w, r)
}
}
// Attach function attaches to HTTP router to apply for every API call.
func (mid *middleware) applyBaseMiddleware(fn http.HandlerFunc) http.HandlerFunc {
// Apply middleware in reverse order (bottom up)
handler := fn
handler = mid.URLProcessorMiddleware(handler)
handler = mid.RequestBodySizeLimitMiddleware(handler)
return handler
}
// RequestBodySizeLimitMiddleware limits the size of request bodies to prevent DoS attacks.
// Default limit is 10MB for most requests, which is sufficient for JSON metadata payloads.
func (mid *middleware) RequestBodySizeLimitMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 10MB limit for request bodies
// This is sufficient for JSON metadata while preventing abuse
const maxBodySize = 10 * 1024 * 1024 // 10MB
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
}
fn(w, r)
}
}
// Shutdown shuts down the middleware.
func (mid *middleware) Shutdown(ctx context.Context) {
// Log a message to indicate that the HTTP server is shutting down.
}

View file

@ -0,0 +1,35 @@
package middleware
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/jwt"
)
// Wire provider for middleware
func ProvideMiddleware(
logger *zap.Logger,
jwtProvider jwt.JWTProvider,
userGetByIDUseCase uc_user.UserGetByIDUseCase,
) Middleware {
return NewMiddleware(logger, jwtProvider, userGetByIDUseCase)
}
// ProvideRateLimitMiddleware provides the rate limit middleware for Wire DI
func ProvideRateLimitMiddleware(
logger *zap.Logger,
loginRateLimiter ratelimit.LoginRateLimiter,
) *RateLimitMiddleware {
return NewRateLimitMiddleware(logger, loginRateLimiter)
}
// ProvideSecurityHeadersMiddleware provides the security headers middleware for Wire DI
func ProvideSecurityHeadersMiddleware(
config *config.Config,
) *SecurityHeadersMiddleware {
return NewSecurityHeadersMiddleware(config)
}

View file

@ -0,0 +1,175 @@
// Package middleware provides HTTP middleware for the MapleFile backend.
package middleware
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
// RateLimitMiddleware provides rate limiting functionality for HTTP endpoints
type RateLimitMiddleware struct {
logger *zap.Logger
loginRateLimiter ratelimit.LoginRateLimiter
}
// NewRateLimitMiddleware creates a new rate limit middleware
func NewRateLimitMiddleware(logger *zap.Logger, loginRateLimiter ratelimit.LoginRateLimiter) *RateLimitMiddleware {
return &RateLimitMiddleware{
logger: logger.Named("RateLimitMiddleware"),
loginRateLimiter: loginRateLimiter,
}
}
// LoginRateLimit applies login-specific rate limiting to auth endpoints
// CWE-307: Protects against brute force attacks on authentication endpoints
func (m *RateLimitMiddleware) LoginRateLimit(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract client IP
clientIP := m.extractClientIP(r)
// Extract email from request body (need to buffer and restore)
email := m.extractEmailFromRequest(r)
// Check rate limit
allowed, isLocked, remainingAttempts, err := m.loginRateLimiter.CheckAndRecordAttempt(ctx, email, clientIP)
if err != nil {
// Log error but allow request (fail open for availability)
m.logger.Warn("Rate limiter error, allowing request",
zap.Error(err),
zap.String("ip", validation.MaskIP(clientIP)))
next(w, r)
return
}
// Check if account is locked
if isLocked {
m.logger.Warn("Login attempt on locked account",
zap.String("ip", validation.MaskIP(clientIP)),
zap.String("path", r.URL.Path))
problem := httperror.NewTooManyRequestsError(
"Account temporarily locked due to too many failed attempts. Please try again later.")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Check if IP rate limit exceeded
if !allowed {
m.logger.Warn("Rate limit exceeded",
zap.String("ip", validation.MaskIP(clientIP)),
zap.String("path", r.URL.Path),
zap.Int("remaining_attempts", remainingAttempts))
problem := httperror.NewTooManyRequestsError(
"Too many requests. Please slow down and try again later.")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
// Add remaining attempts to response header for client awareness
if remainingAttempts > 0 && remainingAttempts <= 3 {
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remainingAttempts))
}
next(w, r)
}
}
// AuthRateLimit applies general rate limiting to auth endpoints
// For endpoints like registration, email verification, etc.
func (m *RateLimitMiddleware) AuthRateLimit(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Extract client IP for rate limiting key
clientIP := m.extractClientIP(r)
// Use the login rate limiter for IP-based checking only
// This provides basic protection against automated attacks
ctx := r.Context()
allowed, _, _, err := m.loginRateLimiter.CheckAndRecordAttempt(ctx, "", clientIP)
if err != nil {
// Fail open
m.logger.Warn("Rate limiter error, allowing request", zap.Error(err))
next(w, r)
return
}
if !allowed {
m.logger.Warn("Auth rate limit exceeded",
zap.String("ip", validation.MaskIP(clientIP)),
zap.String("path", r.URL.Path))
problem := httperror.NewTooManyRequestsError(
"Too many requests from this IP. Please try again later.")
problem.WithInstance(r.URL.Path).
WithTraceID(httperror.ExtractRequestID(r))
httperror.RespondWithProblem(w, problem)
return
}
next(w, r)
}
}
// extractClientIP extracts the real client IP from the request
func (m *RateLimitMiddleware) extractClientIP(r *http.Request) string {
// Check X-Forwarded-For header first (for reverse proxies)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the first IP in the chain
ips := strings.Split(xff, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Fall back to RemoteAddr
// Remove port if present
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx != -1 {
ip = ip[:idx]
}
return ip
}
// extractEmailFromRequest extracts email from JSON request body
// It buffers the body so it can be read again by the handler
func (m *RateLimitMiddleware) extractEmailFromRequest(r *http.Request) string {
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
return ""
}
// Restore body for handler
r.Body = io.NopCloser(bytes.NewBuffer(body))
// Parse JSON to extract email
var req struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &req); err != nil {
return ""
}
return strings.ToLower(strings.TrimSpace(req.Email))
}

View file

@ -0,0 +1,64 @@
package middleware
import (
"net/http"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
)
// SecurityHeadersMiddleware adds security headers to all HTTP responses.
// These headers help protect against common web vulnerabilities.
type SecurityHeadersMiddleware struct {
config *config.Config
}
// NewSecurityHeadersMiddleware creates a new security headers middleware.
func NewSecurityHeadersMiddleware(config *config.Config) *SecurityHeadersMiddleware {
return &SecurityHeadersMiddleware{
config: config,
}
}
// Handler wraps an http.Handler to add security headers to all responses.
func (m *SecurityHeadersMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// X-Content-Type-Options: Prevents MIME-type sniffing attacks
// Browser will strictly follow the declared Content-Type
w.Header().Set("X-Content-Type-Options", "nosniff")
// X-Frame-Options: Prevents clickjacking attacks
// DENY = page cannot be displayed in any iframe
w.Header().Set("X-Frame-Options", "DENY")
// X-XSS-Protection: Enables browser's built-in XSS filter
// mode=block = block the entire page if attack is detected
// Note: Largely superseded by CSP, but still useful for older browsers
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Referrer-Policy: Controls how much referrer information is sent
// strict-origin-when-cross-origin = full URL for same-origin, origin only for cross-origin
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Cache-Control: Prevent caching of sensitive responses
// Especially important for auth endpoints
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
// Permissions-Policy: Restricts browser features (formerly Feature-Policy)
// Disables potentially dangerous features like geolocation, camera, microphone
w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
// Content-Security-Policy: Prevents XSS and other code injection attacks
// For API-only backend: deny all content sources and frame embedding
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
// Strict-Transport-Security (HSTS): Forces HTTPS for the specified duration
// Only set in production where HTTPS is properly configured
// max-age=31536000 = 1 year in seconds
// includeSubDomains = applies to all subdomains
if m.config.App.Environment == "production" {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,29 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware/url.go
package middleware
import (
"context"
"net/http"
"strings"
)
// URLProcessorMiddleware Middleware will split the full URL path into slash-sperated parts and save to
// the context to flow downstream in the app for this particular request.
func (mid *middleware) URLProcessorMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Split path into slash-separated parts, for example, path "/foo/bar"
// gives p==["foo", "bar"] and path "/" gives p==[""]. Our API starts with
// "/api", as a result we will start the array slice at "1".
p := strings.Split(r.URL.Path, "/")[1:]
// log.Println(p) // For debugging purposes only.
// Open our program's context based on the request and save the
// slash-seperated array from our URL path.
ctx := r.Context()
ctx = context.WithValue(ctx, "url_split", p)
// Flow to the next middleware.
fn(w, r.WithContext(ctx))
}
}

View file

@ -0,0 +1,111 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware/utils.go
package middleware
import (
"regexp"
"go.uber.org/zap"
)
type protectedRoute struct {
pattern string
regex *regexp.Regexp
}
var (
exactPaths = make(map[string]bool)
patternRoutes []protectedRoute
)
func init() {
// Exact matches
exactPaths = map[string]bool{
"/api/v1/me": true,
"/api/v1/me/delete": true,
"/api/v1/me/blocked-emails": true,
"/api/v1/dashboard": true,
"/api/v1/collections": true,
"/api/v1/collections/filtered": true,
"/api/v1/collections/root": true,
"/api/v1/collections/shared": true,
"/api/v1/collections/sync": true, // Sync collections endpoint
"/api/v1/files": true,
"/api/v1/files/pending": true, // Three-step workflow file-create endpoint: Start
"/api/v1/files/recent": true,
"/api/v1/files/sync": true, // Sync files endpoint
"/api/v1/files/delete-multiple": true, // Delete multiple files endpoint
"/api/v1/invites/send-email": true, // Send invitation email to non-registered user
"/api/v1/tags": true, // List and create tags
"/api/v1/tags/search": true, // Search by tags
"/iam/api/v1/users/lookup": true, // User public key lookup (requires auth)
}
// Pattern matches
patterns := []string{
// Blocked Email patterns
"^/api/v1/me/blocked-emails/[^/]+$", // Delete specific blocked email
// Collection patterns (plural routes)
"^/api/v1/collections/[a-zA-Z0-9-]+$", // Individual collection operations
"^/api/v1/collections/[a-zA-Z0-9-]+/move$", // Move collection
"^/api/v1/collections/[a-zA-Z0-9-]+/share$", // Share collection
"^/api/v1/collections/[a-zA-Z0-9-]+/members$", // Collection members
"^/api/v1/collections/[a-zA-Z0-9-]+/members/[a-zA-Z0-9-]+$", // Remove specific member
"^/api/v1/collections/[a-zA-Z0-9-]+/archive$", // Archive collection
"^/api/v1/collections/[a-zA-Z0-9-]+/restore$", // Restore collection
"^/api/v1/collections-by-parent/[a-zA-Z0-9-]+$", // Collections by parent
// Collection patterns (singular routes for files)
"^/api/v1/collection/[a-zA-Z0-9-]+/files$", // Collection files (singular)
// File patterns (singular routes)
"^/api/v1/file/[a-zA-Z0-9-]+$", // Individual file operations
"^/api/v1/file/[a-zA-Z0-9-]+/data$", // File data
"^/api/v1/file/[a-zA-Z0-9-]+/upload-url$", // File upload URL
"^/api/v1/file/[a-zA-Z0-9-]+/download-url$", // File download URL
"^/api/v1/file/[a-zA-Z0-9-]+/complete$", // Complete file upload
"^/api/v1/file/[a-zA-Z0-9-]+/archive$", // Archive file
"^/api/v1/file/[a-zA-Z0-9-]+/restore$", // Restore file
// Tag patterns
"^/api/v1/tags/[a-zA-Z0-9-]+$", // Individual tag operations (GET, PUT, DELETE)
"^/api/v1/tags/[a-zA-Z0-9-]+/assign$", // Assign tag to entity
"^/api/v1/tags/[a-zA-Z0-9-]+/entities/[a-zA-Z0-9-]+$", // Unassign tag from entity
"^/api/v1/tags/for/collection/[a-zA-Z0-9-]+$", // Get tags for collection
"^/api/v1/tags/for/file/[a-zA-Z0-9-]+$", // Get tags for file
"^/api/v1/tags/collections$", // List collections by tag
"^/api/v1/tags/files$", // List files by tag
}
// Precompile patterns
patternRoutes = make([]protectedRoute, len(patterns))
for i, pattern := range patterns {
patternRoutes[i] = protectedRoute{
pattern: pattern,
regex: regexp.MustCompile(pattern),
}
}
}
func isProtectedPath(logger *zap.Logger, path string) bool {
// Check exact matches first (O(1) lookup)
if exactPaths[path] {
logger.Debug("✅ found via map - url is protected",
zap.String("path", path))
return true
}
// Check patterns
for _, route := range patternRoutes {
if route.regex.MatchString(path) {
logger.Debug("✅ found via regex - url is protected",
zap.String("path", path))
return true
}
}
logger.Debug("❌ not found",
zap.String("path", path))
return false
}

View file

@ -0,0 +1,221 @@
package http
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/blockedemail"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/collection"
commonhttp "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/common"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/dashboard"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/file"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/inviteemail"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/me"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/user"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
)
// ProvideHandlers wires all HTTP handlers for Wire DI
func ProvideHandlers(
cfg *config.Config,
logger *zap.Logger,
// Common
versionHandler *commonhttp.MapleFileVersionHTTPHandler,
// Dashboard
getDashboard *dashboard.GetDashboardHTTPHandler,
// Me
getMe *me.GetMeHTTPHandler,
updateMe *me.PutUpdateMeHTTPHandler,
deleteMe *me.DeleteMeHTTPHandler,
// User
userPublicLookup *user.UserPublicLookupHTTPHandler,
// Blocked Email
createBlockedEmail *blockedemail.CreateBlockedEmailHTTPHandler,
listBlockedEmails *blockedemail.ListBlockedEmailsHTTPHandler,
deleteBlockedEmail *blockedemail.DeleteBlockedEmailHTTPHandler,
// Invite Email
sendInviteEmail *inviteemail.SendInviteEmailHTTPHandler,
// Collection - Basic CRUD
createCollection *collection.CreateCollectionHTTPHandler,
getCollection *collection.GetCollectionHTTPHandler,
listUserCollections *collection.ListUserCollectionsHTTPHandler,
updateCollection *collection.UpdateCollectionHTTPHandler,
softDeleteCollection *collection.SoftDeleteCollectionHTTPHandler,
archiveCollection *collection.ArchiveCollectionHTTPHandler,
restoreCollection *collection.RestoreCollectionHTTPHandler,
// Collection - Hierarchical
findCollectionsByParent *collection.FindCollectionsByParentHTTPHandler,
findRootCollections *collection.FindRootCollectionsHTTPHandler,
moveCollection *collection.MoveCollectionHTTPHandler,
// Collection - Sharing
shareCollection *collection.ShareCollectionHTTPHandler,
removeMember *collection.RemoveMemberHTTPHandler,
listSharedCollections *collection.ListSharedCollectionsHTTPHandler,
// Collection - Filtered
getFilteredCollections *collection.GetFilteredCollectionsHTTPHandler,
// Collection - Sync
collectionSync *collection.CollectionSyncHTTPHandler,
// File - CRUD
softDeleteFile *file.SoftDeleteFileHTTPHandler,
deleteMultipleFiles *file.DeleteMultipleFilesHTTPHandler,
getFile *file.GetFileHTTPHandler,
listFilesByCollection *file.ListFilesByCollectionHTTPHandler,
updateFile *file.UpdateFileHTTPHandler,
createPendingFile *file.CreatePendingFileHTTPHandler,
completeFileUpload *file.CompleteFileUploadHTTPHandler,
getPresignedUploadURL *file.GetPresignedUploadURLHTTPHandler,
getPresignedDownloadURL *file.GetPresignedDownloadURLHTTPHandler,
reportDownloadCompleted *file.ReportDownloadCompletedHTTPHandler,
archiveFile *file.ArchiveFileHTTPHandler,
restoreFile *file.RestoreFileHTTPHandler,
listRecentFiles *file.ListRecentFilesHTTPHandler,
// File - Sync
fileSync *file.FileSyncHTTPHandler,
// Tag handlers
createTag *tag.CreateTagHTTPHandler,
listTags *tag.ListTagsHTTPHandler,
getTag *tag.GetTagHTTPHandler,
updateTag *tag.UpdateTagHTTPHandler,
deleteTag *tag.DeleteTagHTTPHandler,
assignTag *tag.AssignTagHTTPHandler,
unassignTag *tag.UnassignTagHTTPHandler,
getTagsForCollection *tag.GetTagsForCollectionHTTPHandler,
getTagsForFile *tag.GetTagsForFileHTTPHandler,
listCollectionsByTag *tag.ListCollectionsByTagHandler,
listFilesByTag *tag.ListFilesByTagHandler,
searchByTags *tag.SearchByTagsHandler,
) *Handlers {
return NewHandlers(
// Common
versionHandler,
// Dashboard
getDashboard,
// Me
getMe,
updateMe,
deleteMe,
// User
userPublicLookup,
// Blocked Email
createBlockedEmail,
listBlockedEmails,
deleteBlockedEmail,
// Invite Email
sendInviteEmail,
// Collection - Basic CRUD
createCollection,
getCollection,
listUserCollections,
updateCollection,
softDeleteCollection,
archiveCollection,
restoreCollection,
// Collection - Hierarchical
findCollectionsByParent,
findRootCollections,
moveCollection,
// Collection - Sharing
shareCollection,
removeMember,
listSharedCollections,
// Collection - Filtered
getFilteredCollections,
// Collection Sync
collectionSync,
// File - CRUD
softDeleteFile,
deleteMultipleFiles,
getFile,
listFilesByCollection,
updateFile,
createPendingFile,
completeFileUpload,
getPresignedUploadURL,
getPresignedDownloadURL,
reportDownloadCompleted,
archiveFile,
restoreFile,
listRecentFiles,
// File Sync
fileSync,
// Tag handlers
createTag,
listTags,
getTag,
updateTag,
deleteTag,
assignTag,
unassignTag,
getTagsForCollection,
getTagsForFile,
listCollectionsByTag,
listFilesByTag,
searchByTags,
)
}
// ProvideServer provides the HTTP server for Wire DI
func ProvideServer(
cfg *config.Config,
logger *zap.Logger,
handlers *Handlers,
registerService svc_auth.RegisterService,
verifyEmailService svc_auth.VerifyEmailService,
resendVerificationService svc_auth.ResendVerificationService,
requestOTTService svc_auth.RequestOTTService,
verifyOTTService svc_auth.VerifyOTTService,
completeLoginService svc_auth.CompleteLoginService,
refreshTokenService svc_auth.RefreshTokenService,
recoveryInitiateService svc_auth.RecoveryInitiateService,
recoveryVerifyService svc_auth.RecoveryVerifyService,
recoveryCompleteService svc_auth.RecoveryCompleteService,
rateLimitMiddleware *middleware.RateLimitMiddleware,
securityHeadersMiddleware *middleware.SecurityHeadersMiddleware,
) *WireServer {
return NewWireServer(
cfg,
logger,
handlers,
registerService,
verifyEmailService,
resendVerificationService,
requestOTTService,
verifyOTTService,
completeLoginService,
refreshTokenService,
recoveryInitiateService,
recoveryVerifyService,
recoveryCompleteService,
rateLimitMiddleware,
securityHeadersMiddleware,
)
}

View file

@ -0,0 +1,119 @@
package http
// routes.go - HTTP route registration for MapleFile backend
// This file documents all available endpoints
/*
ROUTE STRUCTURE:
Public Routes (No authentication required):
GET /health - Health check
GET /version - Version information
POST /api/v1/auth/register - User registration
POST /api/v1/auth/login - User login
POST /api/v1/auth/refresh - Refresh JWT token
POST /api/v1/auth/logout - Logout
Protected Routes (Authentication required):
Auth & Profile:
GET /api/v1/me - Get current user profile
PUT /api/v1/me - Update user profile
DELETE /api/v1/me - Delete user account
POST /api/v1/me/verify - Verify user profile
Dashboard:
GET /api/v1/dashboard - Get dashboard data
Invitations:
POST /api/v1/invites/send-email - Send invitation to non-registered user
Collections (Basic CRUD):
POST /api/v1/collections - Create collection
GET /api/v1/collections - List user collections
GET /api/v1/collections/{id} - Get collection by ID
PUT /api/v1/collections/{id} - Update collection
DELETE /api/v1/collections/{id} - Delete collection
Collections (Hierarchical):
GET /api/v1/collections/root - Get root collections
GET /api/v1/collections/parent/{parent_id} - Get collections by parent
PUT /api/v1/collections/{id}/move - Move collection
Collections (Sharing):
POST /api/v1/collections/{id}/share - Share collection
DELETE /api/v1/collections/{id}/members/{user_id} - Remove member
GET /api/v1/collections/shared - List shared collections
Collections (Operations):
PUT /api/v1/collections/{id}/archive - Archive collection
PUT /api/v1/collections/{id}/restore - Restore collection
GET /api/v1/collections/filtered - Get filtered collections
POST /api/v1/collections/sync - Sync collections
Files (Basic CRUD):
POST /api/v1/files/pending - Create pending file
POST /api/v1/files/{id}/complete - Complete file upload
GET /api/v1/files/{id} - Get file by ID
PUT /api/v1/files/{id} - Update file
DELETE /api/v1/files/{id} - Delete file
POST /api/v1/files/delete-multiple - Delete multiple files
Files (Operations):
GET /api/v1/files/collection/{collection_id} - List files by collection
GET /api/v1/files/recent - List recent files
PUT /api/v1/files/{id}/archive - Archive file
PUT /api/v1/files/{id}/restore - Restore file
POST /api/v1/files/sync - Sync files
Files (Storage):
GET /api/v1/files/{id}/upload-url - Get presigned upload URL
GET /api/v1/files/{id}/download-url - Get presigned download URL
Total Endpoints: ~47
*/
// RouteInfo represents information about a route
type RouteInfo struct {
Method string
Path string
Description string
Protected bool
}
// GetAllRoutes returns a list of all available routes
func GetAllRoutes() []RouteInfo {
return []RouteInfo{
// Public routes
{Method: "GET", Path: "/health", Description: "Health check", Protected: false},
{Method: "GET", Path: "/version", Description: "Version information", Protected: false},
{Method: "POST", Path: "/api/v1/auth/register", Description: "User registration", Protected: false},
{Method: "POST", Path: "/api/v1/auth/login", Description: "User login", Protected: false},
{Method: "POST", Path: "/api/v1/auth/refresh", Description: "Refresh JWT token", Protected: false},
{Method: "POST", Path: "/api/v1/auth/logout", Description: "Logout", Protected: false},
// Profile routes
{Method: "GET", Path: "/api/v1/me", Description: "Get current user profile", Protected: true},
{Method: "PUT", Path: "/api/v1/me", Description: "Update user profile", Protected: true},
{Method: "DELETE", Path: "/api/v1/me", Description: "Delete user account", Protected: true},
// Dashboard
{Method: "GET", Path: "/api/v1/dashboard", Description: "Get dashboard data", Protected: true},
// Collections
{Method: "POST", Path: "/api/v1/collections", Description: "Create collection", Protected: true},
{Method: "GET", Path: "/api/v1/collections", Description: "List collections", Protected: true},
{Method: "GET", Path: "/api/v1/collections/{id}", Description: "Get collection", Protected: true},
{Method: "PUT", Path: "/api/v1/collections/{id}", Description: "Update collection", Protected: true},
{Method: "DELETE", Path: "/api/v1/collections/{id}", Description: "Delete collection", Protected: true},
// Files
{Method: "POST", Path: "/api/v1/files/pending", Description: "Create pending file", Protected: true},
{Method: "POST", Path: "/api/v1/files/{id}/complete", Description: "Complete upload", Protected: true},
{Method: "GET", Path: "/api/v1/files/{id}", Description: "Get file", Protected: true},
{Method: "PUT", Path: "/api/v1/files/{id}", Description: "Update file", Protected: true},
{Method: "DELETE", Path: "/api/v1/files/{id}", Description: "Delete file", Protected: true},
// ... (More routes will be registered in Phase 6)
}
}

View file

@ -0,0 +1,347 @@
package http
import (
"context"
"fmt"
"net/http"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
// Server represents the HTTP server
type Server struct {
server *http.Server
logger *zap.Logger
config *config.Config
mux *http.ServeMux
middleware middleware.Middleware
handlers *Handlers
}
// NewServer creates a new HTTP server
func NewServer(
cfg *config.Config,
logger *zap.Logger,
mw middleware.Middleware,
handlers *Handlers,
) *Server {
mux := http.NewServeMux()
s := &Server{
logger: logger,
config: cfg,
mux: mux,
middleware: mw,
handlers: handlers,
}
// Register routes
s.registerRoutes()
// Create HTTP server with configuration
s.server = &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
Handler: s.applyMiddleware(mux),
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
IdleTimeout: cfg.Server.IdleTimeout,
}
return s
}
// Start starts the HTTP server
func (s *Server) Start() error {
s.logger.Info("Starting HTTP server",
zap.String("address", s.server.Addr),
zap.String("environment", s.config.App.Environment),
)
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// Shutdown gracefully shuts down the HTTP server
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("Shutting down HTTP server")
return s.server.Shutdown(ctx)
}
// applyMiddleware applies global middleware to the handler
func (s *Server) applyMiddleware(handler http.Handler) http.Handler {
// Apply middleware in reverse order (last applied is executed first)
// TODO: Add more middleware in Phase 6
// Logging middleware (outermost)
handler = s.loggingMiddleware(handler)
// CORS middleware
handler = s.corsMiddleware(handler)
// Recovery middleware (catches panics)
handler = s.recoveryMiddleware(handler)
return handler
}
// registerRoutes registers all HTTP routes
func (s *Server) registerRoutes() {
s.logger.Info("Registering HTTP routes")
// ===== Public Routes =====
s.mux.HandleFunc("GET /health", s.healthCheckHandler)
s.mux.HandleFunc("GET /version", s.versionHandler)
// TODO: Auth routes to be implemented in Phase 7
// s.mux.HandleFunc("POST /api/v1/auth/register", authHandler.Register)
// s.mux.HandleFunc("POST /api/v1/auth/login", authHandler.Login)
// ===== Protected Routes =====
// Me / Profile routes
s.mux.HandleFunc("GET /api/v1/me", s.handlers.GetMe.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/me", s.handlers.UpdateMe.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/me", s.handlers.DeleteMe.ServeHTTP)
// Blocked Email routes
s.mux.HandleFunc("POST /api/v1/me/blocked-emails", s.handlers.CreateBlockedEmail.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/me/blocked-emails", s.handlers.ListBlockedEmails.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/me/blocked-emails/{email}", s.handlers.DeleteBlockedEmail.ServeHTTP)
// Invite Email routes
s.mux.HandleFunc("POST /api/v1/invites/send-email", s.handlers.SendInviteEmail.ServeHTTP)
// Dashboard
s.mux.HandleFunc("GET /api/v1/dashboard", s.handlers.GetDashboard.ServeHTTP)
// Collections - Basic CRUD
s.mux.HandleFunc("POST /api/v1/collections", s.handlers.CreateCollection.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/collections", s.handlers.ListUserCollections.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/collections/{id}", s.handlers.GetCollection.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/collections/{id}", s.handlers.UpdateCollection.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/collections/{id}", s.handlers.SoftDeleteCollection.ServeHTTP)
// Collections - Hierarchical
s.mux.HandleFunc("GET /api/v1/collections/root", s.handlers.FindRootCollections.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/collections/parent/{parent_id}", s.handlers.FindCollectionsByParent.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/collections/{id}/move", s.handlers.MoveCollection.ServeHTTP)
// Collections - Sharing
s.mux.HandleFunc("POST /api/v1/collections/{id}/share", s.handlers.ShareCollection.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/collections/{id}/members/{user_id}", s.handlers.RemoveMember.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/collections/shared", s.handlers.ListSharedCollections.ServeHTTP)
// Collections - Operations
s.mux.HandleFunc("PUT /api/v1/collections/{id}/archive", s.handlers.ArchiveCollection.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/collections/{id}/restore", s.handlers.RestoreCollection.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/collections/filtered", s.handlers.GetFilteredCollections.ServeHTTP)
s.mux.HandleFunc("POST /api/v1/collections/sync", s.handlers.CollectionSync.ServeHTTP)
// Files - Non-parameterized routes (no wildcards)
s.mux.HandleFunc("POST /api/v1/files/pending", s.handlers.CreatePendingFile.ServeHTTP)
s.mux.HandleFunc("POST /api/v1/files/delete-multiple", s.handlers.DeleteMultipleFiles.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/files/recent", s.handlers.ListRecentFiles.ServeHTTP)
s.mux.HandleFunc("POST /api/v1/files/sync", s.handlers.FileSync.ServeHTTP)
// Files - Parameterized routes under /file/ prefix (singular) to avoid conflicts
s.mux.HandleFunc("POST /api/v1/file/{id}/complete", s.handlers.CompleteFileUpload.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/file/{id}/archive", s.handlers.ArchiveFile.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/file/{id}/restore", s.handlers.RestoreFile.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/file/{id}/upload-url", s.handlers.GetPresignedUploadURL.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/file/{id}/download-url", s.handlers.GetPresignedDownloadURL.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/file/{id}", s.handlers.GetFile.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/file/{id}", s.handlers.UpdateFile.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/file/{id}", s.handlers.SoftDeleteFile.ServeHTTP)
// Files by collection - under /collection/ prefix
s.mux.HandleFunc("GET /api/v1/collection/{collection_id}/files", s.handlers.ListFilesByCollection.ServeHTTP)
// Tags - Basic CRUD
s.mux.HandleFunc("POST /api/v1/tags", s.handlers.CreateTag.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/tags", s.handlers.ListTags.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/tags/{id}", s.handlers.GetTag.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/tags/{id}", s.handlers.UpdateTag.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/tags/{id}", s.handlers.DeleteTag.ServeHTTP)
// Tags - Assignment
s.mux.HandleFunc("POST /api/v1/tags/{id}/assign", s.handlers.AssignTag.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/tags/{tagId}/entities/{entityId}", s.handlers.UnassignTag.ServeHTTP)
// Tags - Entity lookups
s.mux.HandleFunc("GET /api/v1/collections/{id}/tags", s.handlers.GetTagsForCollection.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/files/{id}/tags", s.handlers.GetTagsForFile.ServeHTTP)
// Tags - Multi-tag filtering (requires tags query parameter with comma-separated UUIDs)
s.mux.HandleFunc("GET /api/v1/tags/collections", s.handlers.ListCollectionsByTag.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/tags/files", s.handlers.ListFilesByTag.ServeHTTP)
s.logger.Info("HTTP routes registered", zap.Int("total_routes", 58))
}
// Health check handler
func (s *Server) healthCheckHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"healthy","service":"maplefile-backend"}`))
}
// Version handler
func (s *Server) versionHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
version := fmt.Sprintf(`{"version":"%s","environment":"%s"}`,
s.config.App.Version,
s.config.App.Environment)
w.Write([]byte(version))
}
// Middleware implementations
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip logging health check requests to reduce noise
if r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
start := time.Now()
// Wrap response writer to capture status code
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
s.logger.Info("HTTP request",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.Int("status", wrapped.statusCode),
zap.Duration("duration", duration),
zap.String("remote_addr", validation.MaskIP(r.RemoteAddr)),
)
})
}
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get the origin from the request
origin := r.Header.Get("Origin")
// Build allowed origins map
allowedOrigins := make(map[string]bool)
// In development, always allow localhost origins
if s.config.App.Environment == "development" {
allowedOrigins["http://localhost:5173"] = true // Vite dev server
allowedOrigins["http://localhost:5174"] = true // Alternative Vite port
allowedOrigins["http://localhost:3000"] = true // Common React port
allowedOrigins["http://127.0.0.1:5173"] = true
allowedOrigins["http://127.0.0.1:5174"] = true
allowedOrigins["http://127.0.0.1:3000"] = true
}
// Add production origins from configuration
for _, allowedOrigin := range s.config.Security.AllowedOrigins {
if allowedOrigin != "" {
allowedOrigins[allowedOrigin] = true
}
}
// Check if the request origin is allowed
if allowedOrigins[origin] {
// SECURITY FIX: Validate origin before setting CORS headers
// CWE-942: Permissive Cross-domain Policy with Untrusted Domains
// OWASP A05:2021: Security Misconfiguration - Secure CORS configuration
// Prevent wildcard origin with credentials (major security risk)
if origin == "*" {
s.logger.Error("CRITICAL: Wildcard origin (*) cannot be used with credentials",
zap.String("path", r.URL.Path))
// Don't set CORS headers for wildcard - this is a misconfiguration
next.ServeHTTP(w, r)
return
}
// In production, enforce HTTPS origins for security
if s.config.App.Environment == "production" {
if len(origin) >= 5 && origin[:5] == "http:" {
s.logger.Warn("Non-HTTPS origin rejected in production",
zap.String("origin", origin),
zap.String("path", r.URL.Path))
// Don't set CORS headers for non-HTTPS origins in production
next.ServeHTTP(w, r)
return
}
}
// Set CORS headers for validated origins
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Only set credentials for specific, non-wildcard origins
// This prevents credential leakage to untrusted domains
if origin != "*" && origin != "" {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Max-Age", "3600") // Cache preflight for 1 hour
s.logger.Debug("CORS headers added",
zap.String("origin", origin),
zap.String("path", r.URL.Path),
zap.Bool("credentials_allowed", origin != "*"))
} else if origin != "" {
// Log rejected origins for debugging
s.logger.Warn("CORS request from disallowed origin",
zap.String("origin", origin),
zap.String("path", r.URL.Path))
}
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
s.logger.Error("Panic recovered",
zap.Any("error", err),
zap.String("path", r.URL.Path),
)
problem := httperror.NewInternalServerError("An unexpected error occurred")
problem.WithInstance(r.URL.Path)
httperror.RespondWithProblem(w, problem)
}
}()
next.ServeHTTP(w, r)
})
}
// responseWriter wraps http.ResponseWriter to capture status code
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}

View file

@ -0,0 +1,134 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/assign.go
package tag
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type AssignTagRequest struct {
EntityID string `json:"entity_id"`
EntityType string `json:"entity_type"` // "collection" or "file"
}
type AssignTagHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service *svc_tag.TagService
middleware middleware.Middleware
}
func NewAssignTagHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
middleware middleware.Middleware,
) *AssignTagHTTPHandler {
logger = logger.Named("AssignTagHTTPHandler")
return &AssignTagHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*AssignTagHTTPHandler) Pattern() string {
return "POST /api/v1/tags/{id}/assign"
}
func (h *AssignTagHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *AssignTagHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
) (*AssignTagRequest, error) {
var requestData AssignTagRequest
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON)
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("Failed to decode assign tag request",
zap.Error(err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewBadRequestError("Invalid request payload")
}
return &requestData, nil
}
func (h *AssignTagHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user ID from JWT context
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
if !ok {
h.logger.Error("Failed to get user ID from context")
httperror.ResponseError(w, httperror.NewUnauthorizedError("User not authenticated"))
return
}
// Get tag ID from path
tagIDStr := r.PathValue("id")
if tagIDStr == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("Tag ID is required"))
return
}
tagID, err := gocql.ParseUUID(tagIDStr)
if err != nil {
h.logger.Error("Invalid tag ID", zap.Error(err), zap.String("id", tagIDStr))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid tag ID format"))
return
}
// Parse request
req, err := h.unmarshalRequest(ctx, r)
if err != nil {
h.logger.Error("Failed to unmarshal request", zap.Error(err))
httperror.ResponseError(w, err)
return
}
// Parse entity ID
entityID, err := gocql.ParseUUID(req.EntityID)
if err != nil {
h.logger.Error("Invalid entity ID", zap.Error(err), zap.String("entity_id", req.EntityID))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid entity ID format"))
return
}
// Assign tag
if err := h.service.AssignTag(ctx, userID, tagID, entityID, req.EntityType); err != nil {
h.logger.Error("Failed to assign tag",
zap.Error(err),
zap.String("tag_id", tagIDStr),
zap.String("entity_id", req.EntityID),
zap.String("entity_type", req.EntityType),
)
httperror.ResponseError(w, httperror.NewInternalServerError("Failed to assign tag"))
return
}
// Return response
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,202 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/create.go
package tag
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
dom_crypto "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto"
dom_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
// CreateTagRequest contains encrypted tag data from the client (E2EE)
// The client sends a complete Tag object with encrypted fields
type CreateTagRequest struct {
ID string `json:"id"`
UserID string `json:"user_id"`
EncryptedName string `json:"encrypted_name"`
EncryptedColor string `json:"encrypted_color"`
EncryptedTagKey *EncryptedTagKeyDTO `json:"encrypted_tag_key"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
Version uint64 `json:"version"`
State string `json:"state"`
}
// EncryptedTagKeyDTO for JSON (un)marshaling
type EncryptedTagKeyDTO struct {
Ciphertext string `json:"ciphertext"` // Base64 encoded
Nonce string `json:"nonce"` // Base64 encoded
}
type CreateTagHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service *svc_tag.TagService
middleware middleware.Middleware
}
func NewCreateTagHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
middleware middleware.Middleware,
) *CreateTagHTTPHandler {
logger = logger.Named("CreateTagHTTPHandler")
return &CreateTagHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*CreateTagHTTPHandler) Pattern() string {
return "POST /api/v1/tags"
}
func (h *CreateTagHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *CreateTagHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
) (*CreateTagRequest, error) {
var requestData CreateTagRequest
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON)
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("Failed to decode create tag request",
zap.Error(err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewBadRequestError("Invalid request payload")
}
return &requestData, nil
}
func (h *CreateTagHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user ID from JWT context
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
if !ok {
h.logger.Error("Failed to get user ID from context")
httperror.ResponseError(w, httperror.NewUnauthorizedError("User not authenticated"))
return
}
// Parse request
req, err := h.unmarshalRequest(ctx, r)
if err != nil {
h.logger.Error("Failed to unmarshal request", zap.Error(err))
httperror.ResponseError(w, err)
return
}
// Parse tag ID
tagID, err := gocql.ParseUUID(req.ID)
if err != nil {
h.logger.Error("Invalid tag ID", zap.Error(err), zap.String("id", req.ID))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid tag ID"))
return
}
// Parse timestamps
createdAt, err := time.Parse(time.RFC3339, req.CreatedAt)
if err != nil {
h.logger.Error("Invalid created_at timestamp", zap.Error(err))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid created_at timestamp"))
return
}
modifiedAt, err := time.Parse(time.RFC3339, req.ModifiedAt)
if err != nil {
h.logger.Error("Invalid modified_at timestamp", zap.Error(err))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid modified_at timestamp"))
return
}
// Decode encrypted tag key
var encryptedTagKey *dom_crypto.EncryptedTagKey
if req.EncryptedTagKey != nil {
// Decode ciphertext from URL-safe base64
ciphertext, err := base64.RawURLEncoding.DecodeString(req.EncryptedTagKey.Ciphertext)
if err != nil {
// Fallback to standard encoding
ciphertext, err = base64.StdEncoding.DecodeString(req.EncryptedTagKey.Ciphertext)
if err != nil {
h.logger.Error("Failed to decode tag key ciphertext", zap.Error(err))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid tag key ciphertext"))
return
}
}
// Decode nonce from URL-safe base64
nonce, err := base64.RawURLEncoding.DecodeString(req.EncryptedTagKey.Nonce)
if err != nil {
// Fallback to standard encoding
nonce, err = base64.StdEncoding.DecodeString(req.EncryptedTagKey.Nonce)
if err != nil {
h.logger.Error("Failed to decode tag key nonce", zap.Error(err))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid tag key nonce"))
return
}
}
encryptedTagKey = &dom_crypto.EncryptedTagKey{
Ciphertext: ciphertext,
Nonce: nonce,
KeyVersion: 1,
}
}
// Create tag domain object
tag := &dom_tag.Tag{
ID: tagID,
UserID: userID,
EncryptedName: req.EncryptedName,
EncryptedColor: req.EncryptedColor,
EncryptedTagKey: encryptedTagKey,
CreatedAt: createdAt,
ModifiedAt: modifiedAt,
Version: req.Version,
State: req.State,
}
// Create tag
err = h.service.CreateTag(ctx, tag)
if err != nil {
h.logger.Error("Failed to create tag",
zap.Error(err),
zap.String("tag_id", tagID.String()),
)
httperror.ResponseError(w, httperror.NewInternalServerError("Failed to create tag"))
return
}
// Return response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(tag)
}

View file

@ -0,0 +1,81 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/delete.go
package tag
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type DeleteTagHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service *svc_tag.TagService
middleware middleware.Middleware
}
func NewDeleteTagHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
middleware middleware.Middleware,
) *DeleteTagHTTPHandler {
logger = logger.Named("DeleteTagHTTPHandler")
return &DeleteTagHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*DeleteTagHTTPHandler) Pattern() string {
return "DELETE /api/v1/tags/{id}"
}
func (h *DeleteTagHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *DeleteTagHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user ID from JWT context
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
if !ok {
h.logger.Error("Failed to get user ID from context")
httperror.ResponseError(w, httperror.NewUnauthorizedError("User not authenticated"))
return
}
// Get tag ID from path
idStr := r.PathValue("id")
if idStr == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("Tag ID is required"))
return
}
tagID, err := gocql.ParseUUID(idStr)
if err != nil {
h.logger.Error("Invalid tag ID", zap.Error(err), zap.String("id", idStr))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid tag ID format"))
return
}
// Delete tag
if err := h.service.DeleteTag(ctx, userID, tagID); err != nil {
h.logger.Error("Failed to delete tag", zap.Error(err), zap.String("id", idStr))
httperror.ResponseError(w, httperror.NewInternalServerError("Failed to delete tag"))
return
}
// Return response
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,76 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/get.go
package tag
import (
"encoding/json"
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetTagHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service *svc_tag.TagService
middleware middleware.Middleware
}
func NewGetTagHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
middleware middleware.Middleware,
) *GetTagHTTPHandler {
logger = logger.Named("GetTagHTTPHandler")
return &GetTagHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*GetTagHTTPHandler) Pattern() string {
return "GET /api/v1/tags/{id}"
}
func (h *GetTagHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *GetTagHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get tag ID from path
idStr := r.PathValue("id")
if idStr == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("Tag ID is required"))
return
}
tagID, err := gocql.ParseUUID(idStr)
if err != nil {
h.logger.Error("Invalid tag ID", zap.Error(err), zap.String("id", idStr))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid tag ID format"))
return
}
// Get tag
tag, err := h.service.GetTag(ctx, tagID)
if err != nil {
h.logger.Error("Failed to get tag", zap.Error(err), zap.String("id", idStr))
httperror.ResponseError(w, httperror.NewNotFoundError("Tag not found"))
return
}
// Return response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(tag)
}

View file

@ -0,0 +1,142 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/get_for_entity.go
package tag
import (
"encoding/json"
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetTagsForCollectionHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service *svc_tag.TagService
middleware middleware.Middleware
}
func NewGetTagsForCollectionHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
middleware middleware.Middleware,
) *GetTagsForCollectionHTTPHandler {
logger = logger.Named("GetTagsForCollectionHTTPHandler")
return &GetTagsForCollectionHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*GetTagsForCollectionHTTPHandler) Pattern() string {
return "GET /api/v1/collections/{id}/tags"
}
func (h *GetTagsForCollectionHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *GetTagsForCollectionHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get collection ID from path
idStr := r.PathValue("id")
if idStr == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("Collection ID is required"))
return
}
collectionID, err := gocql.ParseUUID(idStr)
if err != nil {
h.logger.Error("Invalid collection ID", zap.Error(err), zap.String("id", idStr))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid collection ID format"))
return
}
// Get tags for collection
tags, err := h.service.GetTagsForEntity(ctx, collectionID, "collection")
if err != nil {
h.logger.Error("Failed to get tags for collection", zap.Error(err), zap.String("id", idStr))
httperror.ResponseError(w, httperror.NewInternalServerError("Failed to get tags"))
return
}
// Return response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"tags": tags,
})
}
// GetTagsForFileHTTPHandler handles getting tags for a file
type GetTagsForFileHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service *svc_tag.TagService
middleware middleware.Middleware
}
func NewGetTagsForFileHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
middleware middleware.Middleware,
) *GetTagsForFileHTTPHandler {
logger = logger.Named("GetTagsForFileHTTPHandler")
return &GetTagsForFileHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*GetTagsForFileHTTPHandler) Pattern() string {
return "GET /api/v1/files/{id}/tags"
}
func (h *GetTagsForFileHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *GetTagsForFileHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get file ID from path
idStr := r.PathValue("id")
if idStr == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("File ID is required"))
return
}
fileID, err := gocql.ParseUUID(idStr)
if err != nil {
h.logger.Error("Invalid file ID", zap.Error(err), zap.String("id", idStr))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid file ID format"))
return
}
// Get tags for file
tags, err := h.service.GetTagsForEntity(ctx, fileID, "file")
if err != nil {
h.logger.Error("Failed to get tags for file", zap.Error(err), zap.String("id", idStr))
httperror.ResponseError(w, httperror.NewInternalServerError("Failed to get tags"))
return
}
// Return response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"tags": tags,
})
}

View file

@ -0,0 +1,73 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/list.go
package tag
import (
"encoding/json"
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ListTagsHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service *svc_tag.TagService
middleware middleware.Middleware
}
func NewListTagsHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
middleware middleware.Middleware,
) *ListTagsHTTPHandler {
logger = logger.Named("ListTagsHTTPHandler")
return &ListTagsHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*ListTagsHTTPHandler) Pattern() string {
return "GET /api/v1/tags"
}
func (h *ListTagsHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *ListTagsHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user ID from JWT context
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
if !ok {
h.logger.Error("Failed to get user ID from context")
httperror.ResponseError(w, httperror.NewUnauthorizedError("User not authenticated"))
return
}
// List tags
tags, err := h.service.ListUserTags(ctx, userID)
if err != nil {
h.logger.Error("Failed to list tags", zap.Error(err))
httperror.ResponseError(w, httperror.NewInternalServerError("Failed to list tags"))
return
}
// Return response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"tags": tags,
})
}

View file

@ -0,0 +1,98 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/list_collections_by_tag.go
package tag
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ListCollectionsByTagHandler struct {
UseCase *tag.ListCollectionsByTagUseCase
Logger *zap.Logger
}
func NewListCollectionsByTagHandler(
useCase *tag.ListCollectionsByTagUseCase,
logger *zap.Logger,
) *ListCollectionsByTagHandler {
return &ListCollectionsByTagHandler{
UseCase: useCase,
Logger: logger,
}
}
func (h *ListCollectionsByTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract user ID from context (set by auth middleware)
userID, ok := ctx.Value("user_id").(gocql.UUID)
if !ok {
httperror.ResponseError(w, httperror.NewUnauthorizedError("user not authenticated"))
return
}
// Get tags parameter (required, comma-separated UUIDs)
tagsParam := r.URL.Query().Get("tags")
if tagsParam == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("tags parameter is required"))
return
}
// Parse comma-separated tag IDs
tagIDStrs := strings.Split(tagsParam, ",")
if len(tagIDStrs) == 0 {
httperror.ResponseError(w, httperror.NewBadRequestError("at least one tag ID is required"))
return
}
tagIDs := make([]gocql.UUID, 0, len(tagIDStrs))
for _, idStr := range tagIDStrs {
id, err := gocql.ParseUUID(strings.TrimSpace(idStr))
if err != nil {
httperror.ResponseError(w, httperror.NewBadRequestError("invalid tag ID: "+idStr))
return
}
tagIDs = append(tagIDs, id)
}
// Parse pagination parameters
limitStr := r.URL.Query().Get("limit")
limit := 50 // default
if limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 && parsedLimit <= 100 {
limit = parsedLimit
}
}
cursor := r.URL.Query().Get("cursor")
// Execute multi-tag use case
collections, nextCursor, err := h.UseCase.Execute(ctx, userID, tagIDs, limit, cursor)
if err != nil {
h.Logger.Error("failed to list collections by tags",
zap.Int("tag_count", len(tagIDs)),
zap.Error(err))
httperror.ResponseError(w, httperror.NewInternalServerError("failed to list collections"))
return
}
// Build response
response := map[string]interface{}{
"collections": collections,
"cursor": nextCursor,
"has_more": nextCursor != "",
"tag_count": len(tagIDs),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}

View file

@ -0,0 +1,98 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/list_files_by_tag.go
package tag
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ListFilesByTagHandler struct {
UseCase *tag.ListFilesByTagUseCase
Logger *zap.Logger
}
func NewListFilesByTagHandler(
useCase *tag.ListFilesByTagUseCase,
logger *zap.Logger,
) *ListFilesByTagHandler {
return &ListFilesByTagHandler{
UseCase: useCase,
Logger: logger,
}
}
func (h *ListFilesByTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract user ID from context (set by auth middleware)
userID, ok := ctx.Value("user_id").(gocql.UUID)
if !ok {
httperror.ResponseError(w, httperror.NewUnauthorizedError("user not authenticated"))
return
}
// Get tags parameter (required, comma-separated UUIDs)
tagsParam := r.URL.Query().Get("tags")
if tagsParam == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("tags parameter is required"))
return
}
// Parse comma-separated tag IDs
tagIDStrs := strings.Split(tagsParam, ",")
if len(tagIDStrs) == 0 {
httperror.ResponseError(w, httperror.NewBadRequestError("at least one tag ID is required"))
return
}
tagIDs := make([]gocql.UUID, 0, len(tagIDStrs))
for _, idStr := range tagIDStrs {
id, err := gocql.ParseUUID(strings.TrimSpace(idStr))
if err != nil {
httperror.ResponseError(w, httperror.NewBadRequestError("invalid tag ID: "+idStr))
return
}
tagIDs = append(tagIDs, id)
}
// Parse pagination parameters
limitStr := r.URL.Query().Get("limit")
limit := 50 // default
if limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 && parsedLimit <= 100 {
limit = parsedLimit
}
}
cursor := r.URL.Query().Get("cursor")
// Execute multi-tag use case
files, nextCursor, err := h.UseCase.Execute(ctx, userID, tagIDs, limit, cursor)
if err != nil {
h.Logger.Error("failed to list files by tags",
zap.Int("tag_count", len(tagIDs)),
zap.Error(err))
httperror.ResponseError(w, httperror.NewInternalServerError("failed to list files"))
return
}
// Build response
response := map[string]interface{}{
"files": files,
"cursor": nextCursor,
"has_more": nextCursor != "",
"tag_count": len(tagIDs),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}

View file

@ -0,0 +1,116 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/provider.go
package tag
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/tag"
uc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/tag"
)
// Wire providers for tag HTTP handlers
func ProvideCreateTagHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
mw middleware.Middleware,
) *CreateTagHTTPHandler {
return NewCreateTagHTTPHandler(cfg, logger, service, mw)
}
func ProvideListTagsHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
mw middleware.Middleware,
) *ListTagsHTTPHandler {
return NewListTagsHTTPHandler(cfg, logger, service, mw)
}
func ProvideGetTagHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
mw middleware.Middleware,
) *GetTagHTTPHandler {
return NewGetTagHTTPHandler(cfg, logger, service, mw)
}
func ProvideUpdateTagHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
mw middleware.Middleware,
) *UpdateTagHTTPHandler {
return NewUpdateTagHTTPHandler(cfg, logger, service, mw)
}
func ProvideDeleteTagHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
mw middleware.Middleware,
) *DeleteTagHTTPHandler {
return NewDeleteTagHTTPHandler(cfg, logger, service, mw)
}
func ProvideAssignTagHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
mw middleware.Middleware,
) *AssignTagHTTPHandler {
return NewAssignTagHTTPHandler(cfg, logger, service, mw)
}
func ProvideUnassignTagHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
mw middleware.Middleware,
) *UnassignTagHTTPHandler {
return NewUnassignTagHTTPHandler(cfg, logger, service, mw)
}
func ProvideGetTagsForCollectionHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
mw middleware.Middleware,
) *GetTagsForCollectionHTTPHandler {
return NewGetTagsForCollectionHTTPHandler(cfg, logger, service, mw)
}
func ProvideGetTagsForFileHTTPHandler(
cfg *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
mw middleware.Middleware,
) *GetTagsForFileHTTPHandler {
return NewGetTagsForFileHTTPHandler(cfg, logger, service, mw)
}
func ProvideListCollectionsByTagHandler(
useCase *uc_tag.ListCollectionsByTagUseCase,
logger *zap.Logger,
) *ListCollectionsByTagHandler {
return NewListCollectionsByTagHandler(useCase, logger)
}
func ProvideListFilesByTagHandler(
useCase *uc_tag.ListFilesByTagUseCase,
logger *zap.Logger,
) *ListFilesByTagHandler {
return NewListFilesByTagHandler(useCase, logger)
}
func ProvideSearchByTagsHandler(
service *svc_tag.SearchByTagsService,
logger *zap.Logger,
mw middleware.Middleware,
) *SearchByTagsHandler {
return NewSearchByTagsHandler(service, logger, mw)
}

View file

@ -0,0 +1,102 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/search_by_tags.go
package tag
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type SearchByTagsHandler struct {
Service *svc_tag.SearchByTagsService
Logger *zap.Logger
middleware middleware.Middleware
}
func NewSearchByTagsHandler(
service *svc_tag.SearchByTagsService,
logger *zap.Logger,
mid middleware.Middleware,
) *SearchByTagsHandler {
return &SearchByTagsHandler{
Service: service,
Logger: logger,
middleware: mid,
}
}
func (h *SearchByTagsHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *SearchByTagsHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract user ID from context (set by auth middleware)
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
if !ok {
httperror.ResponseError(w, httperror.NewUnauthorizedError("user not authenticated"))
return
}
// Get tags parameter (required, comma-separated UUIDs)
tagsParam := r.URL.Query().Get("tags")
if tagsParam == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("tags parameter is required"))
return
}
// Parse comma-separated tag IDs
tagIDStrs := strings.Split(tagsParam, ",")
if len(tagIDStrs) == 0 {
httperror.ResponseError(w, httperror.NewBadRequestError("at least one tag ID is required"))
return
}
tagIDs := make([]gocql.UUID, 0, len(tagIDStrs))
for _, idStr := range tagIDStrs {
id, err := gocql.ParseUUID(strings.TrimSpace(idStr))
if err != nil {
httperror.ResponseError(w, httperror.NewBadRequestError("invalid tag ID: "+idStr))
return
}
tagIDs = append(tagIDs, id)
}
// Parse limit parameter
limitStr := r.URL.Query().Get("limit")
limit := 50 // default
if limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 && parsedLimit <= 100 {
limit = parsedLimit
}
}
// Execute search
result, err := h.Service.Execute(ctx, &svc_tag.SearchByTagsRequest{
UserID: userID,
TagIDs: tagIDs,
Limit: limit,
})
if err != nil {
h.Logger.Error("failed to search by tags",
zap.Int("tag_count", len(tagIDs)),
zap.Error(err))
httperror.ResponseError(w, httperror.NewInternalServerError("failed to search by tags"))
return
}
// Return JSON response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result)
}

View file

@ -0,0 +1,98 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/unassign.go
package tag
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type UnassignTagHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service *svc_tag.TagService
middleware middleware.Middleware
}
func NewUnassignTagHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
middleware middleware.Middleware,
) *UnassignTagHTTPHandler {
logger = logger.Named("UnassignTagHTTPHandler")
return &UnassignTagHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*UnassignTagHTTPHandler) Pattern() string {
return "DELETE /api/v1/tags/{tagId}/entities/{entityId}"
}
func (h *UnassignTagHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *UnassignTagHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get tag ID from path
tagIDStr := r.PathValue("tagId")
if tagIDStr == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("Tag ID is required"))
return
}
tagID, err := gocql.ParseUUID(tagIDStr)
if err != nil {
h.logger.Error("Invalid tag ID", zap.Error(err), zap.String("id", tagIDStr))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid tag ID format"))
return
}
// Get entity ID from path
entityIDStr := r.PathValue("entityId")
if entityIDStr == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("Entity ID is required"))
return
}
entityID, err := gocql.ParseUUID(entityIDStr)
if err != nil {
h.logger.Error("Invalid entity ID", zap.Error(err), zap.String("id", entityIDStr))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid entity ID format"))
return
}
// Get entity type from query parameter
entityType := r.URL.Query().Get("entity_type")
if entityType == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("Entity type is required"))
return
}
// Unassign tag
if err := h.service.UnassignTag(ctx, tagID, entityID, entityType); err != nil {
h.logger.Error("Failed to unassign tag",
zap.Error(err),
zap.String("tag_id", tagIDStr),
zap.String("entity_id", entityIDStr),
zap.String("entity_type", entityType),
)
httperror.ResponseError(w, httperror.NewInternalServerError("Failed to unassign tag"))
return
}
// Return response
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,201 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/tag/update.go
package tag
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
dom_crypto "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto"
dom_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/tag"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
// UpdateTagRequest contains encrypted tag data from the client (E2EE)
type UpdateTagRequest struct {
ID string `json:"id"`
UserID string `json:"user_id"`
EncryptedName string `json:"encrypted_name"`
EncryptedColor string `json:"encrypted_color"`
EncryptedTagKey *EncryptedTagKeyDTO `json:"encrypted_tag_key"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
Version uint64 `json:"version"`
State string `json:"state"`
}
type UpdateTagHTTPHandler struct {
config *config.Configuration
logger *zap.Logger
service *svc_tag.TagService
middleware middleware.Middleware
}
func NewUpdateTagHTTPHandler(
config *config.Configuration,
logger *zap.Logger,
service *svc_tag.TagService,
middleware middleware.Middleware,
) *UpdateTagHTTPHandler {
logger = logger.Named("UpdateTagHTTPHandler")
return &UpdateTagHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*UpdateTagHTTPHandler) Pattern() string {
return "PUT /api/v1/tags/{id}"
}
func (h *UpdateTagHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.middleware.Attach(h.Execute)(w, req)
}
func (h *UpdateTagHTTPHandler) unmarshalRequest(
ctx context.Context,
r *http.Request,
) (*UpdateTagRequest, error) {
var requestData UpdateTagRequest
defer r.Body.Close()
var rawJSON bytes.Buffer
teeReader := io.TeeReader(r.Body, &rawJSON)
err := json.NewDecoder(teeReader).Decode(&requestData)
if err != nil {
h.logger.Error("Failed to decode update tag request",
zap.Error(err),
zap.String("json", rawJSON.String()),
)
return nil, httperror.NewBadRequestError("Invalid request payload")
}
return &requestData, nil
}
func (h *UpdateTagHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user ID from JWT context
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
if !ok {
h.logger.Error("Failed to get user ID from context")
httperror.ResponseError(w, httperror.NewUnauthorizedError("User not authenticated"))
return
}
// Get tag ID from path
idStr := r.PathValue("id")
if idStr == "" {
httperror.ResponseError(w, httperror.NewBadRequestError("Tag ID is required"))
return
}
tagID, err := gocql.ParseUUID(idStr)
if err != nil {
h.logger.Error("Invalid tag ID", zap.Error(err), zap.String("id", idStr))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid tag ID format"))
return
}
// Parse request
req, err := h.unmarshalRequest(ctx, r)
if err != nil {
h.logger.Error("Failed to unmarshal request", zap.Error(err))
httperror.ResponseError(w, err)
return
}
// Parse timestamps
createdAt, err := time.Parse(time.RFC3339, req.CreatedAt)
if err != nil {
h.logger.Error("Invalid created_at timestamp", zap.Error(err))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid created_at timestamp"))
return
}
modifiedAt, err := time.Parse(time.RFC3339, req.ModifiedAt)
if err != nil {
h.logger.Error("Invalid modified_at timestamp", zap.Error(err))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid modified_at timestamp"))
return
}
// Decode encrypted tag key
var encryptedTagKey *dom_crypto.EncryptedTagKey
if req.EncryptedTagKey != nil {
// Decode ciphertext from URL-safe base64
ciphertext, err := base64.RawURLEncoding.DecodeString(req.EncryptedTagKey.Ciphertext)
if err != nil {
// Fallback to standard encoding
ciphertext, err = base64.StdEncoding.DecodeString(req.EncryptedTagKey.Ciphertext)
if err != nil {
h.logger.Error("Failed to decode tag key ciphertext", zap.Error(err))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid tag key ciphertext"))
return
}
}
// Decode nonce from URL-safe base64
nonce, err := base64.RawURLEncoding.DecodeString(req.EncryptedTagKey.Nonce)
if err != nil {
// Fallback to standard encoding
nonce, err = base64.StdEncoding.DecodeString(req.EncryptedTagKey.Nonce)
if err != nil {
h.logger.Error("Failed to decode tag key nonce", zap.Error(err))
httperror.ResponseError(w, httperror.NewBadRequestError("Invalid tag key nonce"))
return
}
}
encryptedTagKey = &dom_crypto.EncryptedTagKey{
Ciphertext: ciphertext,
Nonce: nonce,
KeyVersion: 1,
}
}
// Create tag domain object
tag := &dom_tag.Tag{
ID: tagID,
UserID: userID,
EncryptedName: req.EncryptedName,
EncryptedColor: req.EncryptedColor,
EncryptedTagKey: encryptedTagKey,
CreatedAt: createdAt,
ModifiedAt: modifiedAt,
Version: req.Version,
State: req.State,
}
// Update tag
err = h.service.UpdateTag(ctx, tag)
if err != nil {
h.logger.Error("Failed to update tag",
zap.Error(err),
zap.String("tag_id", tagID.String()),
)
httperror.ResponseError(w, httperror.NewInternalServerError("Failed to update tag"))
return
}
// Return response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(tag)
}

View file

@ -0,0 +1,20 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/user/provider.go
package user
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/user"
)
// ProvideUserPublicLookupHTTPHandler provides the user public lookup HTTP handler
func ProvideUserPublicLookupHTTPHandler(
config *config.Config,
logger *zap.Logger,
service svc_user.UserPublicLookupService,
middleware middleware.Middleware,
) *UserPublicLookupHTTPHandler {
return NewUserPublicLookupHTTPHandler(config, logger, service, middleware)
}

View file

@ -0,0 +1,84 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/user/publiclookup.go
package user
import (
"encoding/json"
"net/http"
"strings"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
type UserPublicLookupHTTPHandler struct {
config *config.Config
logger *zap.Logger
service svc_user.UserPublicLookupService
middleware middleware.Middleware
}
func NewUserPublicLookupHTTPHandler(
config *config.Config,
logger *zap.Logger,
service svc_user.UserPublicLookupService,
middleware middleware.Middleware,
) *UserPublicLookupHTTPHandler {
logger = logger.Named("UserPublicLookupHTTPHandler")
return &UserPublicLookupHTTPHandler{
config: config,
logger: logger,
service: service,
middleware: middleware,
}
}
func (*UserPublicLookupHTTPHandler) Pattern() string {
return "GET /iam/api/v1/users/lookup"
}
func (h *UserPublicLookupHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply middleware before handling the request
h.middleware.Attach(h.Execute)(w, req)
}
func (h *UserPublicLookupHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 🔍 DEBUG: Log the raw query string to see what's actually received
h.logger.Debug("🔍 Raw query string", zap.String("raw_query", r.URL.RawQuery))
// r.URL.Query().Get() already URL-decodes the parameter automatically
email := r.URL.Query().Get("email")
if email == "" {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("email", "Email parameter required"))
return
}
// 🔍 DEBUG: Log what we got from Query().Get()
h.logger.Debug("🔍 Email from Query().Get()", zap.String("email", validation.MaskEmail(email)))
h.logger.Debug("received email", zap.String("email", validation.MaskEmail(email)))
// Basic email validation
if !strings.Contains(email, "@") {
httperror.RespondWithError(w, r, httperror.NewForBadRequestWithSingleField("email", "Invalid email format"))
return
}
var req svc_user.UserPublicLookupRequestDTO
req.Email = email
response, err := h.service.Execute(ctx, &req)
if err != nil {
httperror.RespondWithError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}

View file

@ -0,0 +1,400 @@
package http
import (
"context"
"fmt"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware"
svc_auth "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
// WireServer is a Wire-compatible HTTP server (without fx.Lifecycle dependency)
type WireServer struct {
server *http.Server
logger *zap.Logger
config *config.Config
mux *http.ServeMux
handlers *Handlers
registerHandler *auth.RegisterHandler
requestOTTHandler *auth.RequestOTTHandler
verifyOTTHandler *auth.VerifyOTTHandler
completeLoginHandler *auth.CompleteLoginHandler
refreshTokenHandler *auth.RefreshTokenHandler
verifyEmailHandler *auth.VerifyEmailHandler
resendVerificationHandler *auth.ResendVerificationHandler
recoveryInitiateHandler *auth.RecoveryInitiateHandler
recoveryVerifyHandler *auth.RecoveryVerifyHandler
recoveryCompleteHandler *auth.RecoveryCompleteHandler
rateLimitMiddleware *middleware.RateLimitMiddleware
securityHeadersMiddleware *middleware.SecurityHeadersMiddleware
}
// NewWireServer creates a Wire-compatible HTTP server
func NewWireServer(
cfg *config.Config,
logger *zap.Logger,
handlers *Handlers,
registerService svc_auth.RegisterService,
verifyEmailService svc_auth.VerifyEmailService,
resendVerificationService svc_auth.ResendVerificationService,
requestOTTService svc_auth.RequestOTTService,
verifyOTTService svc_auth.VerifyOTTService,
completeLoginService svc_auth.CompleteLoginService,
refreshTokenService svc_auth.RefreshTokenService,
recoveryInitiateService svc_auth.RecoveryInitiateService,
recoveryVerifyService svc_auth.RecoveryVerifyService,
recoveryCompleteService svc_auth.RecoveryCompleteService,
rateLimitMiddleware *middleware.RateLimitMiddleware,
securityHeadersMiddleware *middleware.SecurityHeadersMiddleware,
) *WireServer {
mux := http.NewServeMux()
// Initialize auth handlers with services
registerHandler := auth.NewRegisterHandler(logger, registerService)
verifyEmailHandler := auth.NewVerifyEmailHandler(logger, verifyEmailService)
resendVerificationHandler := auth.NewResendVerificationHandler(logger, resendVerificationService)
requestOTTHandler := auth.NewRequestOTTHandler(logger, requestOTTService)
verifyOTTHandler := auth.NewVerifyOTTHandler(logger, verifyOTTService)
completeLoginHandler := auth.NewCompleteLoginHandler(logger, completeLoginService)
refreshTokenHandler := auth.NewRefreshTokenHandler(logger, refreshTokenService)
recoveryInitiateHandler := auth.NewRecoveryInitiateHandler(logger, recoveryInitiateService)
recoveryVerifyHandler := auth.NewRecoveryVerifyHandler(logger, recoveryVerifyService)
recoveryCompleteHandler := auth.NewRecoveryCompleteHandler(logger, recoveryCompleteService)
s := &WireServer{
logger: logger,
config: cfg,
mux: mux,
handlers: handlers,
registerHandler: registerHandler,
requestOTTHandler: requestOTTHandler,
verifyOTTHandler: verifyOTTHandler,
completeLoginHandler: completeLoginHandler,
refreshTokenHandler: refreshTokenHandler,
verifyEmailHandler: verifyEmailHandler,
resendVerificationHandler: resendVerificationHandler,
recoveryInitiateHandler: recoveryInitiateHandler,
recoveryVerifyHandler: recoveryVerifyHandler,
recoveryCompleteHandler: recoveryCompleteHandler,
rateLimitMiddleware: rateLimitMiddleware,
securityHeadersMiddleware: securityHeadersMiddleware,
}
// Register routes (simplified for Phase 2)
s.registerRoutes()
// Create HTTP server with middleware
s.server = &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
Handler: s.applyMiddleware(mux),
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
IdleTimeout: cfg.Server.IdleTimeout,
}
return s
}
// Start starts the HTTP server
func (s *WireServer) Start() error {
s.logger.Info("Starting HTTP server",
zap.String("address", s.server.Addr),
zap.String("environment", s.config.App.Environment),
)
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("HTTP server failed: %w", err)
}
return nil
}
// Shutdown gracefully shuts down the HTTP server
func (s *WireServer) Shutdown(ctx context.Context) error {
s.logger.Info("Shutting down HTTP server")
return s.server.Shutdown(ctx)
}
// registerRoutes registers all HTTP routes
func (s *WireServer) registerRoutes() {
s.logger.Info("Registering HTTP routes")
// ===== Public Routes =====
s.mux.HandleFunc("GET /health", s.healthCheckHandler)
s.mux.HandleFunc("GET /version", s.versionHandler)
// User lookup - Public route for user public key lookup
s.mux.HandleFunc("GET /iam/api/v1/users/lookup", s.handlers.UserPublicLookup.ServeHTTP)
// Auth routes - Registration & Email Verification (with auth rate limiting)
// These endpoints use general auth rate limiting to prevent automated abuse
s.mux.HandleFunc("POST /api/v1/register", s.rateLimitMiddleware.AuthRateLimit(s.registerHandler.ServeHTTP))
s.mux.HandleFunc("POST /api/v1/verify-email-code", s.rateLimitMiddleware.AuthRateLimit(s.verifyEmailHandler.ServeHTTP))
s.mux.HandleFunc("POST /api/v1/resend-verification", s.rateLimitMiddleware.AuthRateLimit(s.resendVerificationHandler.ServeHTTP))
// Auth routes - Login Flow (OTT-based) (with login rate limiting)
// These endpoints use login-specific rate limiting with account lockout
// CWE-307: Protection against brute force attacks
s.mux.HandleFunc("POST /api/v1/request-ott", s.rateLimitMiddleware.LoginRateLimit(s.requestOTTHandler.ServeHTTP))
s.mux.HandleFunc("POST /api/v1/verify-ott", s.rateLimitMiddleware.LoginRateLimit(s.verifyOTTHandler.ServeHTTP))
s.mux.HandleFunc("POST /api/v1/complete-login", s.rateLimitMiddleware.LoginRateLimit(s.completeLoginHandler.ServeHTTP))
// Auth routes - Token Management (with auth rate limiting)
s.mux.HandleFunc("POST /api/v1/token/refresh", s.rateLimitMiddleware.AuthRateLimit(s.refreshTokenHandler.ServeHTTP))
// Auth routes - Account Recovery (with login rate limiting)
// Recovery endpoints need same protection as login to prevent enumeration attacks
s.mux.HandleFunc("POST /api/v1/recovery/initiate", s.rateLimitMiddleware.LoginRateLimit(s.recoveryInitiateHandler.ServeHTTP))
s.mux.HandleFunc("POST /api/v1/recovery/verify", s.rateLimitMiddleware.LoginRateLimit(s.recoveryVerifyHandler.ServeHTTP))
s.mux.HandleFunc("POST /api/v1/recovery/complete", s.rateLimitMiddleware.LoginRateLimit(s.recoveryCompleteHandler.ServeHTTP))
// ===== Protected Routes =====
// Me / Profile routes
s.mux.HandleFunc("GET /api/v1/me", s.handlers.GetMe.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/me", s.handlers.UpdateMe.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/me", s.handlers.DeleteMe.ServeHTTP)
// Blocked Email routes
s.mux.HandleFunc("POST /api/v1/me/blocked-emails", s.handlers.CreateBlockedEmail.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/me/blocked-emails", s.handlers.ListBlockedEmails.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/me/blocked-emails/{email}", s.handlers.DeleteBlockedEmail.ServeHTTP)
// Invite Email routes
s.mux.HandleFunc("POST /api/v1/invites/send-email", s.handlers.SendInviteEmail.ServeHTTP)
// Dashboard
s.mux.HandleFunc("GET /api/v1/dashboard", s.handlers.GetDashboard.ServeHTTP)
// Collections - Basic CRUD
s.mux.HandleFunc("POST /api/v1/collections", s.handlers.CreateCollection.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/collections", s.handlers.ListUserCollections.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/collections/{id}", s.handlers.GetCollection.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/collections/{id}", s.handlers.UpdateCollection.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/collections/{id}", s.handlers.SoftDeleteCollection.ServeHTTP)
// Collections - Hierarchical
s.mux.HandleFunc("GET /api/v1/collections/root", s.handlers.FindRootCollections.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/collections/parent/{parent_id}", s.handlers.FindCollectionsByParent.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/collections/{id}/move", s.handlers.MoveCollection.ServeHTTP)
// Collections - Sharing
s.mux.HandleFunc("POST /api/v1/collections/{id}/share", s.handlers.ShareCollection.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/collections/{id}/members/{user_id}", s.handlers.RemoveMember.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/collections/shared", s.handlers.ListSharedCollections.ServeHTTP)
// Collections - Operations
s.mux.HandleFunc("PUT /api/v1/collections/{id}/archive", s.handlers.ArchiveCollection.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/collections/{id}/restore", s.handlers.RestoreCollection.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/collections/filtered", s.handlers.GetFilteredCollections.ServeHTTP)
s.mux.HandleFunc("POST /api/v1/collections/sync", s.handlers.CollectionSync.ServeHTTP)
// Tags - Basic CRUD (non-parameterized routes first)
s.mux.HandleFunc("POST /api/v1/tags", s.handlers.CreateTag.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/tags", s.handlers.ListTags.ServeHTTP)
// Tags - Filtering operations (specific paths before wildcards)
s.mux.HandleFunc("GET /api/v1/tags/collections", s.handlers.ListCollectionsByTag.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/tags/files", s.handlers.ListFilesByTag.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/tags/search", s.handlers.SearchByTags.ServeHTTP)
// Tags - Retrieval by entity (using /for/ prefix to avoid route conflicts)
s.mux.HandleFunc("GET /api/v1/tags/for/collection/{collection_id}", s.handlers.GetTagsForCollection.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/tags/for/file/{file_id}", s.handlers.GetTagsForFile.ServeHTTP)
// Tags - Assignment operations (specific paths before generic {id})
s.mux.HandleFunc("POST /api/v1/tags/{id}/assign", s.handlers.AssignTag.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/tags/{tagId}/entities/{entityId}", s.handlers.UnassignTag.ServeHTTP)
// Tags - Generic CRUD with {id} parameter (MUST come last to avoid conflicts)
s.mux.HandleFunc("GET /api/v1/tags/{id}", s.handlers.GetTag.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/tags/{id}", s.handlers.UpdateTag.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/tags/{id}", s.handlers.DeleteTag.ServeHTTP)
// Files - Non-parameterized routes (no wildcards)
s.mux.HandleFunc("POST /api/v1/files/pending", s.handlers.CreatePendingFile.ServeHTTP)
s.mux.HandleFunc("POST /api/v1/files/delete-multiple", s.handlers.DeleteMultipleFiles.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/files/recent", s.handlers.ListRecentFiles.ServeHTTP)
s.mux.HandleFunc("POST /api/v1/files/sync", s.handlers.FileSync.ServeHTTP)
// Files - Parameterized routes under /file/ prefix (singular) to avoid conflicts
s.mux.HandleFunc("POST /api/v1/file/{id}/complete", s.handlers.CompleteFileUpload.ServeHTTP)
s.mux.HandleFunc("POST /api/v1/file/{id}/download-completed", s.handlers.ReportDownloadCompleted.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/file/{id}/archive", s.handlers.ArchiveFile.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/file/{id}/restore", s.handlers.RestoreFile.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/file/{id}/upload-url", s.handlers.GetPresignedUploadURL.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/file/{id}/download-url", s.handlers.GetPresignedDownloadURL.ServeHTTP)
s.mux.HandleFunc("GET /api/v1/file/{id}", s.handlers.GetFile.ServeHTTP)
s.mux.HandleFunc("PUT /api/v1/file/{id}", s.handlers.UpdateFile.ServeHTTP)
s.mux.HandleFunc("DELETE /api/v1/file/{id}", s.handlers.SoftDeleteFile.ServeHTTP)
// Files by collection - under /collection/ prefix
s.mux.HandleFunc("GET /api/v1/collection/{collection_id}/files", s.handlers.ListFilesByCollection.ServeHTTP)
s.logger.Info("HTTP routes registered", zap.Int("total_routes", 71))
}
// Health check handler
func (s *WireServer) healthCheckHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"healthy","service":"maplefile-backend","di":"Wire"}`))
}
// Version handler
func (s *WireServer) versionHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
version := fmt.Sprintf(`{"version":"%s","environment":"%s","di":"Wire"}`,
s.config.App.Version,
s.config.App.Environment)
w.Write([]byte(version))
}
// Middleware implementations
// applyMiddleware applies global middleware to the handler
func (s *WireServer) applyMiddleware(handler http.Handler) http.Handler {
// Apply middleware in reverse order (last applied is executed first)
// Logging middleware (outermost)
handler = s.loggingMiddleware(handler)
// CORS middleware
handler = s.corsMiddleware(handler)
// Security headers middleware (adds security headers to all responses)
handler = s.securityHeadersMiddleware.Handler(handler)
// Recovery middleware (catches panics)
handler = s.recoveryMiddleware(handler)
return handler
}
func (s *WireServer) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip logging health check requests
if r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
// Simple logging for Wire version
s.logger.Info("HTTP request",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", validation.MaskIP(r.RemoteAddr)),
)
next.ServeHTTP(w, r)
})
}
func (s *WireServer) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get the origin from the request
origin := r.Header.Get("Origin")
// Build allowed origins map
allowedOrigins := make(map[string]bool)
// In development, always allow localhost origins
if s.config.App.Environment == "development" {
allowedOrigins["http://localhost:5173"] = true // Vite dev server
allowedOrigins["http://localhost:5174"] = true // Alternative Vite port
allowedOrigins["http://localhost:3000"] = true // Common React port
allowedOrigins["http://127.0.0.1:5173"] = true
allowedOrigins["http://127.0.0.1:5174"] = true
allowedOrigins["http://127.0.0.1:3000"] = true
}
// Add production origins from configuration
for _, allowedOrigin := range s.config.Security.AllowedOrigins {
if allowedOrigin != "" {
allowedOrigins[allowedOrigin] = true
}
}
// Check if the request origin is allowed
if allowedOrigins[origin] {
// SECURITY FIX: Validate origin before setting CORS headers
// CWE-942: Permissive Cross-domain Policy with Untrusted Domains
// OWASP A05:2021: Security Misconfiguration - Secure CORS configuration
// Prevent wildcard origin with credentials (major security risk)
if origin == "*" {
s.logger.Error("CRITICAL: Wildcard origin (*) cannot be used with credentials",
zap.String("path", r.URL.Path))
// Don't set CORS headers for wildcard - this is a misconfiguration
next.ServeHTTP(w, r)
return
}
// In production, enforce HTTPS origins for security
if s.config.App.Environment == "production" {
if len(origin) >= 5 && origin[:5] == "http:" {
s.logger.Warn("Non-HTTPS origin rejected in production",
zap.String("origin", origin),
zap.String("path", r.URL.Path))
// Don't set CORS headers for non-HTTPS origins in production
next.ServeHTTP(w, r)
return
}
}
// Set CORS headers for validated origins
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Only set credentials for specific, non-wildcard origins
// This prevents credential leakage to untrusted domains
if origin != "*" && origin != "" {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Max-Age", "3600") // Cache preflight for 1 hour
s.logger.Debug("CORS headers added",
zap.String("origin", origin),
zap.String("path", r.URL.Path),
zap.Bool("credentials_allowed", origin != "*"))
} else if origin != "" {
// Log rejected origins for debugging
s.logger.Warn("CORS request from disallowed origin",
zap.String("origin", origin),
zap.String("path", r.URL.Path))
}
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func (s *WireServer) recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
s.logger.Error("Panic recovered",
zap.Any("error", err),
zap.String("path", r.URL.Path),
)
problem := httperror.NewInternalServerError("An unexpected error occurred")
problem.WithInstance(r.URL.Path)
httperror.RespondWithProblem(w, problem)
}
}()
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,402 @@
# Scheduler with Leader Election
The scheduler has been integrated with leader election to ensure that **scheduled tasks only run on the leader instance**.
## Overview
When multiple instances of the backend are running (e.g., behind a load balancer), you don't want scheduled tasks running on every instance. This would cause:
- ❌ Duplicate task executions
- ❌ Database conflicts
- ❌ Wasted resources
- ❌ Race conditions
With leader election integration:
- ✅ Tasks only execute on the **leader instance**
- ✅ Automatic failover if leader crashes
- ✅ No duplicate executions
- ✅ Safe for multi-instance deployments
## How It Works
```
┌─────────────────────────────────────────────────────────┐
│ Load Balancer │
└─────────────────┬───────────────┬──────────────────────┘
│ │
┌─────────▼────┐ ┌──────▼──────┐ ┌──────────────┐
│ Instance 1 │ │ Instance 2 │ │ Instance 3 │
│ (LEADER) 👑 │ │ (Follower) │ │ (Follower) │
│ │ │ │ │ │
│ Scheduler ✅ │ │ Scheduler ⏸️ │ │ Scheduler ⏸️ │
│ Runs tasks │ │ Skips tasks │ │ Skips tasks │
└──────────────┘ └─────────────┘ └──────────────┘
```
### Execution Flow
1. **All instances** have the scheduler running with registered tasks
2. **All instances** have cron triggers firing at scheduled times
3. **Only the leader** actually executes the task logic
4. **Followers** skip execution (logged at DEBUG level)
Example logs:
**Leader Instance:**
```
2025-01-12T10:00:00.000Z INFO 👑 Leader executing scheduled task task=CleanupOldRecords instance_id=instance-1
2025-01-12T10:00:05.123Z INFO ✅ Task completed successfully task=CleanupOldRecords
```
**Follower Instances:**
```
2025-01-12T10:00:00.000Z DEBUG Skipping task execution - not the leader task=CleanupOldRecords instance_id=instance-2
```
## Usage
### 1. Create a Scheduled Task
```go
package tasks
import (
"context"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/scheduler"
)
type CleanupTask struct {
logger *zap.Logger
// ... other dependencies
}
func NewCleanupTask(logger *zap.Logger) scheduler.Task {
return &CleanupTask{
logger: logger.Named("CleanupTask"),
}
}
func (t *CleanupTask) Name() string {
return "CleanupOldRecords"
}
func (t *CleanupTask) Schedule() string {
// Cron format: every day at 2 AM
return "0 2 * * *"
}
func (t *CleanupTask) Execute(ctx context.Context) error {
t.logger.Info("Starting cleanup of old records")
// Your task logic here
// This will ONLY run on the leader instance
t.logger.Info("Cleanup completed")
return nil
}
```
### 2. Register Tasks with the Scheduler
The scheduler is already wired through Google Wire. To register tasks, you would typically do this in your application startup:
```go
// In app/app.go or wherever you initialize your app
func (app *Application) Start() error {
// ... existing startup code ...
// Register scheduled tasks
if app.scheduler != nil {
// Create and register tasks
cleanupTask := tasks.NewCleanupTask(app.logger)
if err := app.scheduler.RegisterTask(cleanupTask); err != nil {
return fmt.Errorf("failed to register cleanup task: %w", err)
}
metricsTask := tasks.NewMetricsAggregationTask(app.logger)
if err := app.scheduler.RegisterTask(metricsTask); err != nil {
return fmt.Errorf("failed to register metrics task: %w", err)
}
// Start the scheduler
if err := app.scheduler.Start(); err != nil {
return fmt.Errorf("failed to start scheduler: %w", err)
}
}
// ... rest of startup code ...
}
```
### 3. Graceful Shutdown
```go
func (app *Application) Stop() error {
// ... other shutdown code ...
if app.scheduler != nil {
if err := app.scheduler.Stop(); err != nil {
app.logger.Error("Failed to stop scheduler", zap.Error(err))
}
}
// ... rest of shutdown code ...
}
```
## Cron Schedule Format
The scheduler uses standard cron format:
```
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday)
│ │ │ │ │
│ │ │ │ │
* * * * *
```
### Common Examples
```go
"* * * * *" // Every minute
"0 * * * *" // Every hour (on the hour)
"0 0 * * *" // Every day at midnight
"0 2 * * *" // Every day at 2:00 AM
"0 */6 * * *" // Every 6 hours
"0 0 * * 0" // Every Sunday at midnight
"0 0 1 * *" // First day of every month at midnight
"0 9 * * 1-5" // Weekdays at 9:00 AM
"*/5 * * * *" // Every 5 minutes
"0 0,12 * * *" // Twice a day (midnight and noon)
```
## Example Tasks
### Daily Cleanup Task
```go
type DailyCleanupTask struct {
logger *zap.Logger
repo *Repository
}
func (t *DailyCleanupTask) Name() string {
return "DailyCleanup"
}
func (t *DailyCleanupTask) Schedule() string {
return "0 3 * * *" // 3 AM every day
}
func (t *DailyCleanupTask) Execute(ctx context.Context) error {
t.logger.Info("Running daily cleanup")
// Delete old records
cutoffDate := time.Now().AddDate(0, 0, -30) // 30 days ago
if err := t.repo.DeleteOlderThan(ctx, cutoffDate); err != nil {
return fmt.Errorf("cleanup failed: %w", err)
}
return nil
}
```
### Hourly Metrics Task
```go
type MetricsAggregationTask struct {
logger *zap.Logger
metrics *MetricsService
}
func (t *MetricsAggregationTask) Name() string {
return "HourlyMetrics"
}
func (t *MetricsAggregationTask) Schedule() string {
return "0 * * * *" // Every hour
}
func (t *MetricsAggregationTask) Execute(ctx context.Context) error {
t.logger.Info("Aggregating hourly metrics")
if err := t.metrics.AggregateAndSend(ctx); err != nil {
return fmt.Errorf("metrics aggregation failed: %w", err)
}
return nil
}
```
### Cache Warming Task
```go
type CacheWarmingTask struct {
logger *zap.Logger
cache *CacheService
}
func (t *CacheWarmingTask) Name() string {
return "CacheWarming"
}
func (t *CacheWarmingTask) Schedule() string {
return "*/30 * * * *" // Every 30 minutes
}
func (t *CacheWarmingTask) Execute(ctx context.Context) error {
t.logger.Info("Warming application cache")
if err := t.cache.WarmFrequentlyAccessedData(ctx); err != nil {
return fmt.Errorf("cache warming failed: %w", err)
}
return nil
}
```
## Testing
### Local Testing with Multiple Instances
```bash
# Terminal 1 (will become leader)
LEADER_ELECTION_INSTANCE_ID=instance-1 ./maplefile-backend
# Terminal 2 (follower)
LEADER_ELECTION_INSTANCE_ID=instance-2 ./maplefile-backend
# Terminal 3 (follower)
LEADER_ELECTION_INSTANCE_ID=instance-3 ./maplefile-backend
```
Watch the logs:
- **Only instance-1** (leader) will execute tasks
- **instance-2 and instance-3** will skip task execution
Kill instance-1 and watch:
- Either instance-2 or instance-3 becomes the new leader
- The new leader starts executing tasks
- The remaining follower continues to skip
### Testing Task Execution
Create a test task that runs every minute:
```go
type TestTask struct {
logger *zap.Logger
}
func (t *TestTask) Name() string {
return "TestTask"
}
func (t *TestTask) Schedule() string {
return "* * * * *" // Every minute
}
func (t *TestTask) Execute(ctx context.Context) error {
t.logger.Info("TEST TASK EXECUTED - I am the leader!")
return nil
}
```
This makes it easy to see which instance is executing tasks.
## Configuration
### Enable/Disable Leader Election
Leader election for the scheduler is controlled by the `LEADER_ELECTION_ENABLED` environment variable:
```bash
# With leader election (default)
LEADER_ELECTION_ENABLED=true
# Without leader election (all instances run tasks - NOT RECOMMENDED for production)
LEADER_ELECTION_ENABLED=false
```
### Behavior Matrix
| Leader Election | Instances | Task Execution |
|----------------|-----------|----------------|
| Enabled | Single | Tasks run on that instance ✅ |
| Enabled | Multiple | Tasks run ONLY on leader ✅ |
| Disabled | Single | Tasks run on that instance ✅ |
| Disabled | Multiple | Tasks run on ALL instances ⚠️ |
## Best Practices
1. **Always enable leader election in production** when running multiple instances
2. **Keep tasks idempotent** - if a task is accidentally executed twice, it shouldn't cause problems
3. **Handle task failures gracefully** - the scheduler will log errors but continue running
4. **Don't run long tasks** - tasks block the scheduler thread
5. **Use context** - respect context cancellation for graceful shutdown
6. **Log appropriately** - use structured logging to track task execution
7. **Test failover** - verify new leader takes over task execution
## Monitoring
### Check Scheduler Status
You can check which instance is executing tasks by looking at the logs:
```bash
# Leader logs
grep "Leader executing" logs/app.log
# Follower logs (DEBUG level)
grep "Skipping task execution" logs/app.log
```
### Health Check
You could add a health check endpoint to expose scheduler status:
```go
func (h *HealthHandler) SchedulerHealth(w http.ResponseWriter, r *http.Request) {
tasks := h.scheduler.GetRegisteredTasks()
response := map[string]interface{}{
"registered_tasks": tasks,
"leader_election_enabled": h.config.LeaderElection.Enabled,
"is_leader": h.leaderElection.IsLeader(),
"will_execute_tasks": !h.config.LeaderElection.Enabled || h.leaderElection.IsLeader(),
}
json.NewEncoder(w).Encode(response)
}
```
## Troubleshooting
### Tasks not running on any instance
1. Check leader election is working: `grep "Became the leader" logs/app.log`
2. Check tasks are registered: Look for "Registering scheduled task" in logs
3. Check scheduler started: Look for "Scheduler started successfully"
### Tasks running on multiple instances
1. Check `LEADER_ELECTION_ENABLED=true` in all instances
2. Check all instances connect to the same Redis
3. Check network connectivity between instances and Redis
### Tasks not running after leader failure
1. Check `LEADER_ELECTION_LOCK_TTL` - should be < 30s for fast failover
2. Check `LEADER_ELECTION_RETRY_INTERVAL` - followers should retry frequently
3. Check new leader logs for "Became the leader"
4. Verify new leader executes tasks after election
## Related Documentation
- [Leader Election Package](../../../pkg/leaderelection/README.md)
- [Leader Election Examples](../../../pkg/leaderelection/EXAMPLE.md)

View file

@ -0,0 +1,179 @@
package scheduler
import (
"context"
"sync"
"github.com/robfig/cron/v3"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/leaderelection"
)
// Task represents a scheduled task
type Task interface {
Name() string
Schedule() string
Execute(ctx context.Context) error
}
// Scheduler manages all scheduled tasks
// Tasks are only executed if this instance is the leader (when leader election is enabled)
type Scheduler struct {
config *config.Config
logger *zap.Logger
cron *cron.Cron
tasks []Task
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
leaderElection leaderelection.LeaderElection // Leader election instance (can be nil if disabled)
}
// ProvideScheduler creates a new Scheduler instance for Wire DI
func ProvideScheduler(
cfg *config.Config,
logger *zap.Logger,
leaderElection leaderelection.LeaderElection,
) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
logger = logger.Named("Scheduler")
return &Scheduler{
config: cfg,
logger: logger,
cron: cron.New(),
tasks: make([]Task, 0),
ctx: ctx,
cancel: cancel,
leaderElection: leaderElection,
}
}
// RegisterTask registers a task to be scheduled
func (s *Scheduler) RegisterTask(task Task) error {
s.mu.Lock()
defer s.mu.Unlock()
s.logger.Info("Registering scheduled task",
zap.String("task", task.Name()),
zap.String("schedule", task.Schedule()))
// Add task to scheduler
_, err := s.cron.AddFunc(task.Schedule(), func() {
s.executeTask(task)
})
if err != nil {
s.logger.Error("Failed to register task",
zap.String("task", task.Name()),
zap.Error(err))
return err
}
s.tasks = append(s.tasks, task)
s.logger.Info("✅ Task registered successfully",
zap.String("task", task.Name()))
return nil
}
// executeTask executes a task with error handling and logging
// Tasks are only executed if this instance is the leader (when leader election is enabled)
func (s *Scheduler) executeTask(task Task) {
// Check if leader election is enabled
if s.config.LeaderElection.Enabled && s.leaderElection != nil {
// Only execute if this instance is the leader
if !s.leaderElection.IsLeader() {
s.logger.Debug("Skipping task execution - not the leader",
zap.String("task", task.Name()),
zap.String("instance_id", s.leaderElection.GetInstanceID()))
return
}
// Log that leader is executing the task
s.logger.Info("👑 Leader executing scheduled task",
zap.String("task", task.Name()),
zap.String("instance_id", s.leaderElection.GetInstanceID()))
} else {
// Leader election disabled, execute normally
s.logger.Info("Executing scheduled task",
zap.String("task", task.Name()))
}
// Create a context for this execution
ctx := s.ctx
// Execute the task
if err := task.Execute(ctx); err != nil {
s.logger.Error("Task execution failed",
zap.String("task", task.Name()),
zap.Error(err))
return
}
s.logger.Info("✅ Task completed successfully",
zap.String("task", task.Name()))
}
// Start starts the scheduler
func (s *Scheduler) Start() error {
s.mu.RLock()
taskCount := len(s.tasks)
s.mu.RUnlock()
// Log leader election status
if s.config.LeaderElection.Enabled && s.leaderElection != nil {
s.logger.Info("🕐 Starting scheduler with leader election",
zap.Int("registered_tasks", taskCount),
zap.Bool("leader_election_enabled", true),
zap.String("instance_id", s.leaderElection.GetInstanceID()))
s.logger.Info(" Tasks will ONLY execute on the leader instance")
} else {
s.logger.Info("🕐 Starting scheduler without leader election",
zap.Int("registered_tasks", taskCount),
zap.Bool("leader_election_enabled", false))
s.logger.Warn("⚠️ Leader election is disabled - tasks will run on ALL instances")
}
if taskCount == 0 {
s.logger.Warn("No tasks registered, scheduler will run but do nothing")
}
s.cron.Start()
s.logger.Info("✅ Scheduler started successfully")
return nil
}
// Stop stops the scheduler gracefully
func (s *Scheduler) Stop() error {
s.logger.Info("Stopping scheduler...")
// Cancel all running tasks
s.cancel()
// Stop the cron scheduler
ctx := s.cron.Stop()
<-ctx.Done()
s.logger.Info("✅ Scheduler stopped successfully")
return nil
}
// GetRegisteredTasks returns a list of registered task names
func (s *Scheduler) GetRegisteredTasks() []string {
s.mu.RLock()
defer s.mu.RUnlock()
taskNames := make([]string, len(s.tasks))
for i, task := range s.tasks {
taskNames[i] = task.Name()
}
return taskNames
}

View file

@ -0,0 +1,65 @@
package tasks
import (
"context"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/ipanonymization"
)
// IPAnonymizationTask implements scheduler.Task for IP address anonymization
type IPAnonymizationTask struct {
service ipanonymization.AnonymizeOldIPsService
config *config.Config
logger *zap.Logger
}
// ProvideIPAnonymizationTask creates a new IP anonymization task for Wire DI
func ProvideIPAnonymizationTask(
service ipanonymization.AnonymizeOldIPsService,
cfg *config.Config,
logger *zap.Logger,
) *IPAnonymizationTask {
return &IPAnonymizationTask{
service: service,
config: cfg,
logger: logger.Named("IPAnonymizationTask"),
}
}
// Name returns the task name
func (t *IPAnonymizationTask) Name() string {
return "IP Anonymization"
}
// Schedule returns the cron schedule for this task
func (t *IPAnonymizationTask) Schedule() string {
return t.config.Security.IPAnonymizationSchedule
}
// Execute runs the IP anonymization process
func (t *IPAnonymizationTask) Execute(ctx context.Context) error {
if !t.config.Security.IPAnonymizationEnabled {
t.logger.Debug("IP anonymization is disabled")
return nil
}
startTime := time.Now()
t.logger.Info("Starting IP anonymization task")
// Run the anonymization process via the service
if err := t.service.Execute(ctx); err != nil {
t.logger.Error("IP anonymization task failed",
zap.Error(err),
zap.Duration("duration", time.Since(startTime)))
return err
}
t.logger.Info("IP anonymization task completed successfully",
zap.Duration("duration", time.Since(startTime)))
return nil
}