From ce31ab8e67d91797aa574d32c6877a25bb735a6c Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Mon, 15 Dec 2025 10:52:40 -0300 Subject: [PATCH] feat(migration): move admin cloudflare routes to backoffice and cleanup backend --- backend/internal/admin/cloudflare/client.go | 206 -------------- backend/internal/admin/cloudflare/handler.go | 177 ------------ backend/internal/admin/cpanel/client.go | 253 ------------------ backend/internal/admin/cpanel/handler.go | 205 -------------- backend/internal/router/router.go | 23 -- backoffice/.env.example | 9 + backoffice/package-lock.json | 59 +++- backoffice/package.json | 2 + backoffice/src/admin/admin.module.ts | 10 +- backoffice/src/admin/cloudflare.controller.ts | 44 +++ backoffice/src/admin/cloudflare.service.ts | 79 ++++++ 11 files changed, 192 insertions(+), 875 deletions(-) delete mode 100644 backend/internal/admin/cloudflare/client.go delete mode 100644 backend/internal/admin/cloudflare/handler.go delete mode 100644 backend/internal/admin/cpanel/client.go delete mode 100644 backend/internal/admin/cpanel/handler.go create mode 100644 backoffice/src/admin/cloudflare.controller.ts create mode 100644 backoffice/src/admin/cloudflare.service.ts diff --git a/backend/internal/admin/cloudflare/client.go b/backend/internal/admin/cloudflare/client.go deleted file mode 100644 index 55e93fc..0000000 --- a/backend/internal/admin/cloudflare/client.go +++ /dev/null @@ -1,206 +0,0 @@ -package cloudflare - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "time" -) - -const ( - baseURL = "https://api.cloudflare.com/client/v4" -) - -// Client handles Cloudflare API interactions -type Client struct { - apiToken string - zoneID string - http *http.Client -} - -// NewClient creates a new Cloudflare API client -func NewClient() *Client { - return &Client{ - apiToken: os.Getenv("CLOUDFLARE_API_TOKEN"), - zoneID: os.Getenv("CLOUDFLARE_ZONE_ID"), - http: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -// NewClientWithConfig creates a client with custom config -func NewClientWithConfig(apiToken, zoneID string) *Client { - return &Client{ - apiToken: apiToken, - zoneID: zoneID, - http: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -// Zone represents a Cloudflare zone -type Zone struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` -} - -// ZonesResponse is the response from /zones endpoint -type ZonesResponse struct { - Success bool `json:"success"` - Errors []Error `json:"errors"` - Messages []string `json:"messages"` - Result []Zone `json:"result"` -} - -// PurgeResponse is the response from cache purge endpoints -type PurgeResponse struct { - Success bool `json:"success"` - Errors []Error `json:"errors"` - Messages []string `json:"messages"` - Result struct { - ID string `json:"id"` - } `json:"result"` -} - -// Error represents a Cloudflare API error -type Error struct { - Code int `json:"code"` - Message string `json:"message"` -} - -// doRequest executes an HTTP request to Cloudflare API -func (c *Client) doRequest(method, endpoint string, body interface{}) ([]byte, error) { - var reqBody io.Reader - if body != nil { - jsonBody, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - reqBody = bytes.NewBuffer(jsonBody) - } - - req, err := http.NewRequest(method, baseURL+endpoint, reqBody) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+c.apiToken) - req.Header.Set("Content-Type", "application/json") - - resp, err := c.http.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) - } - - return respBody, nil -} - -// GetZones returns all zones for the account -func (c *Client) GetZones() ([]Zone, error) { - respBody, err := c.doRequest("GET", "/zones", nil) - if err != nil { - return nil, err - } - - var response ZonesResponse - if err := json.Unmarshal(respBody, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if !response.Success { - if len(response.Errors) > 0 { - return nil, fmt.Errorf("API error: %s", response.Errors[0].Message) - } - return nil, fmt.Errorf("unknown API error") - } - - return response.Result, nil -} - -// PurgeAll purges all cached content for the zone -func (c *Client) PurgeAll() (*PurgeResponse, error) { - endpoint := fmt.Sprintf("/zones/%s/purge_cache", c.zoneID) - body := map[string]bool{"purge_everything": true} - - respBody, err := c.doRequest("POST", endpoint, body) - if err != nil { - return nil, err - } - - var response PurgeResponse - if err := json.Unmarshal(respBody, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &response, nil -} - -// PurgeByURLs purges specific URLs from cache -func (c *Client) PurgeByURLs(urls []string) (*PurgeResponse, error) { - endpoint := fmt.Sprintf("/zones/%s/purge_cache", c.zoneID) - body := map[string][]string{"files": urls} - - respBody, err := c.doRequest("POST", endpoint, body) - if err != nil { - return nil, err - } - - var response PurgeResponse - if err := json.Unmarshal(respBody, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &response, nil -} - -// PurgeByTags purges content by cache tags (Enterprise only) -func (c *Client) PurgeByTags(tags []string) (*PurgeResponse, error) { - endpoint := fmt.Sprintf("/zones/%s/purge_cache", c.zoneID) - body := map[string][]string{"tags": tags} - - respBody, err := c.doRequest("POST", endpoint, body) - if err != nil { - return nil, err - } - - var response PurgeResponse - if err := json.Unmarshal(respBody, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &response, nil -} - -// PurgeByHosts purges content by hostnames -func (c *Client) PurgeByHosts(hosts []string) (*PurgeResponse, error) { - endpoint := fmt.Sprintf("/zones/%s/purge_cache", c.zoneID) - body := map[string][]string{"hosts": hosts} - - respBody, err := c.doRequest("POST", endpoint, body) - if err != nil { - return nil, err - } - - var response PurgeResponse - if err := json.Unmarshal(respBody, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &response, nil -} diff --git a/backend/internal/admin/cloudflare/handler.go b/backend/internal/admin/cloudflare/handler.go deleted file mode 100644 index e43d31a..0000000 --- a/backend/internal/admin/cloudflare/handler.go +++ /dev/null @@ -1,177 +0,0 @@ -package cloudflare - -import ( - "encoding/json" - "net/http" -) - -// Handler handles Cloudflare admin endpoints -type Handler struct { - client *Client -} - -// NewHandler creates a new Cloudflare handler -func NewHandler() *Handler { - return &Handler{ - client: NewClient(), - } -} - -// PurgeURLsRequest contains URLs to purge from cache -type PurgeURLsRequest struct { - URLs []string `json:"urls"` -} - -// PurgeTagsRequest contains cache tags to purge -type PurgeTagsRequest struct { - Tags []string `json:"tags"` -} - -// PurgeHostsRequest contains hostnames to purge -type PurgeHostsRequest struct { - Hosts []string `json:"hosts"` -} - -// GetZones godoc -// @Summary List Cloudflare Zones -// @Description Returns all zones associated with the Cloudflare account -// @Tags Admin - Cloudflare -// @Accept json -// @Produce json -// @Success 200 {array} Zone -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/admin/cloudflare/zones [get] -func (h *Handler) GetZones(w http.ResponseWriter, r *http.Request) { - zones, err := h.client.GetZones() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(zones) -} - -// PurgeAll godoc -// @Summary Purge All Cache -// @Description Purges all cached content for the configured zone -// @Tags Admin - Cloudflare -// @Accept json -// @Produce json -// @Success 200 {object} PurgeResponse -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/admin/cloudflare/cache/purge-all [post] -func (h *Handler) PurgeAll(w http.ResponseWriter, r *http.Request) { - result, err := h.client.PurgeAll() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -// PurgeByURLs godoc -// @Summary Purge Cache by URLs -// @Description Purges specific URLs from cache -// @Tags Admin - Cloudflare -// @Accept json -// @Produce json -// @Param body body PurgeURLsRequest true "URLs to purge" -// @Success 200 {object} PurgeResponse -// @Failure 400 {string} string "Bad Request" -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/admin/cloudflare/cache/purge-urls [post] -func (h *Handler) PurgeByURLs(w http.ResponseWriter, r *http.Request) { - var req PurgeURLsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if len(req.URLs) == 0 { - http.Error(w, "URLs array is required", http.StatusBadRequest) - return - } - - result, err := h.client.PurgeByURLs(req.URLs) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -// PurgeByTags godoc -// @Summary Purge Cache by Tags -// @Description Purges content by cache tags (Enterprise only) -// @Tags Admin - Cloudflare -// @Accept json -// @Produce json -// @Param body body PurgeTagsRequest true "Tags to purge" -// @Success 200 {object} PurgeResponse -// @Failure 400 {string} string "Bad Request" -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/admin/cloudflare/cache/purge-tags [post] -func (h *Handler) PurgeByTags(w http.ResponseWriter, r *http.Request) { - var req PurgeTagsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if len(req.Tags) == 0 { - http.Error(w, "Tags array is required", http.StatusBadRequest) - return - } - - result, err := h.client.PurgeByTags(req.Tags) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -// PurgeByHosts godoc -// @Summary Purge Cache by Hosts -// @Description Purges content by hostnames -// @Tags Admin - Cloudflare -// @Accept json -// @Produce json -// @Param body body PurgeHostsRequest true "Hosts to purge" -// @Success 200 {object} PurgeResponse -// @Failure 400 {string} string "Bad Request" -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/admin/cloudflare/cache/purge-hosts [post] -func (h *Handler) PurgeByHosts(w http.ResponseWriter, r *http.Request) { - var req PurgeHostsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if len(req.Hosts) == 0 { - http.Error(w, "Hosts array is required", http.StatusBadRequest) - return - } - - result, err := h.client.PurgeByHosts(req.Hosts) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} diff --git a/backend/internal/admin/cpanel/client.go b/backend/internal/admin/cpanel/client.go deleted file mode 100644 index c2158e6..0000000 --- a/backend/internal/admin/cpanel/client.go +++ /dev/null @@ -1,253 +0,0 @@ -package cpanel - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "time" -) - -// Client handles cPanel UAPI interactions -type Client struct { - host string - username string - apiToken string - http *http.Client -} - -// NewClient creates a new cPanel API client -func NewClient() *Client { - return &Client{ - host: os.Getenv("CPANEL_HOST"), - username: os.Getenv("CPANEL_USERNAME"), - apiToken: os.Getenv("CPANEL_API_TOKEN"), - http: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -// NewClientWithConfig creates a client with custom config -func NewClientWithConfig(host, username, apiToken string) *Client { - return &Client{ - host: host, - username: username, - apiToken: apiToken, - http: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -// EmailAccount represents a cPanel email account -type EmailAccount struct { - Email string `json:"email"` - Login string `json:"login"` - Domain string `json:"domain"` - DiskUsed string `json:"diskused"` - DiskQuota string `json:"diskquota"` - HumandiskUsed string `json:"humandiskused"` - HumandiskQuota string `json:"humandiskquota"` -} - -// UAPIResponse is the generic UAPI response structure -type UAPIResponse struct { - APIVersion int `json:"apiversion"` - Func string `json:"func"` - Module string `json:"module"` - Result *json.RawMessage `json:"result"` - Status int `json:"status"` - Errors []string `json:"errors"` - Messages []string `json:"messages"` -} - -// ListEmailsResult is the result from list_pops -type ListEmailsResult struct { - Data []EmailAccount `json:"data"` -} - -// doRequest executes an HTTP request to cPanel UAPI -func (c *Client) doRequest(module, function string, params map[string]string) ([]byte, error) { - // Build UAPI URL - apiURL := fmt.Sprintf("%s/execute/%s/%s", c.host, module, function) - - // Add query parameters - if len(params) > 0 { - values := url.Values{} - for k, v := range params { - values.Set(k, v) - } - apiURL += "?" + values.Encode() - } - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // cPanel API Token authentication - req.Header.Set("Authorization", fmt.Sprintf("cpanel %s:%s", c.username, c.apiToken)) - - resp, err := c.http.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) - } - - return respBody, nil -} - -// ListEmails returns all email accounts for a domain -func (c *Client) ListEmails(domain string) ([]EmailAccount, error) { - params := map[string]string{} - if domain != "" { - params["domain"] = domain - } - - respBody, err := c.doRequest("Email", "list_pops", params) - if err != nil { - return nil, err - } - - var response UAPIResponse - if err := json.Unmarshal(respBody, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if response.Status != 1 { - if len(response.Errors) > 0 { - return nil, fmt.Errorf("API error: %s", response.Errors[0]) - } - return nil, fmt.Errorf("unknown API error") - } - - var result []EmailAccount - if response.Result != nil { - if err := json.Unmarshal(*response.Result, &result); err != nil { - return nil, fmt.Errorf("failed to parse result: %w", err) - } - } - - return result, nil -} - -// CreateEmail creates a new email account -func (c *Client) CreateEmail(email, password string, quota int) error { - // Parse email to get user and domain - params := map[string]string{ - "email": email, - "password": password, - "quota": fmt.Sprintf("%d", quota), // MB, 0 = unlimited - } - - respBody, err := c.doRequest("Email", "add_pop", params) - if err != nil { - return err - } - - var response UAPIResponse - if err := json.Unmarshal(respBody, &response); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if response.Status != 1 { - if len(response.Errors) > 0 { - return fmt.Errorf("API error: %s", response.Errors[0]) - } - return fmt.Errorf("failed to create email") - } - - return nil -} - -// DeleteEmail removes an email account -func (c *Client) DeleteEmail(email string) error { - params := map[string]string{ - "email": email, - } - - respBody, err := c.doRequest("Email", "delete_pop", params) - if err != nil { - return err - } - - var response UAPIResponse - if err := json.Unmarshal(respBody, &response); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if response.Status != 1 { - if len(response.Errors) > 0 { - return fmt.Errorf("API error: %s", response.Errors[0]) - } - return fmt.Errorf("failed to delete email") - } - - return nil -} - -// ChangePassword changes an email account's password -func (c *Client) ChangePassword(email, newPassword string) error { - params := map[string]string{ - "email": email, - "password": newPassword, - } - - respBody, err := c.doRequest("Email", "passwd_pop", params) - if err != nil { - return err - } - - var response UAPIResponse - if err := json.Unmarshal(respBody, &response); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if response.Status != 1 { - if len(response.Errors) > 0 { - return fmt.Errorf("API error: %s", response.Errors[0]) - } - return fmt.Errorf("failed to change password") - } - - return nil -} - -// UpdateQuota updates an email account's disk quota -func (c *Client) UpdateQuota(email string, quotaMB int) error { - params := map[string]string{ - "email": email, - "quota": fmt.Sprintf("%d", quotaMB), - } - - respBody, err := c.doRequest("Email", "edit_pop_quota", params) - if err != nil { - return err - } - - var response UAPIResponse - if err := json.Unmarshal(respBody, &response); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if response.Status != 1 { - if len(response.Errors) > 0 { - return fmt.Errorf("API error: %s", response.Errors[0]) - } - return fmt.Errorf("failed to update quota") - } - - return nil -} diff --git a/backend/internal/admin/cpanel/handler.go b/backend/internal/admin/cpanel/handler.go deleted file mode 100644 index f66c210..0000000 --- a/backend/internal/admin/cpanel/handler.go +++ /dev/null @@ -1,205 +0,0 @@ -package cpanel - -import ( - "encoding/json" - "net/http" -) - -// Handler handles cPanel admin endpoints -type Handler struct { - client *Client -} - -// NewHandler creates a new cPanel handler -func NewHandler() *Handler { - return &Handler{ - client: NewClient(), - } -} - -// CreateEmailRequest is the request body for creating an email -type CreateEmailRequest struct { - Email string `json:"email"` - Password string `json:"password"` - Quota int `json:"quota"` // MB, 0 = unlimited -} - -// ChangePasswordRequest is the request body for changing password -type ChangePasswordRequest struct { - Password string `json:"password"` -} - -// UpdateQuotaRequest is the request body for updating quota -type UpdateQuotaRequest struct { - Quota int `json:"quota"` // MB -} - -// ListEmails godoc -// @Summary List Email Accounts -// @Description Returns all email accounts for the cPanel account -// @Tags Admin - cPanel -// @Accept json -// @Produce json -// @Param domain query string false "Filter by domain" -// @Success 200 {array} EmailAccount -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/admin/cpanel/emails [get] -func (h *Handler) ListEmails(w http.ResponseWriter, r *http.Request) { - domain := r.URL.Query().Get("domain") - - emails, err := h.client.ListEmails(domain) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(emails) -} - -// CreateEmail godoc -// @Summary Create Email Account -// @Description Creates a new email account -// @Tags Admin - cPanel -// @Accept json -// @Produce json -// @Param body body CreateEmailRequest true "Email details" -// @Success 201 {object} map[string]string -// @Failure 400 {string} string "Bad Request" -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/admin/cpanel/emails [post] -func (h *Handler) CreateEmail(w http.ResponseWriter, r *http.Request) { - var req CreateEmailRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.Email == "" || req.Password == "" { - http.Error(w, "Email and password are required", http.StatusBadRequest) - return - } - - if err := h.client.CreateEmail(req.Email, req.Password, req.Quota); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]string{ - "message": "Email created successfully", - "email": req.Email, - }) -} - -// DeleteEmail godoc -// @Summary Delete Email Account -// @Description Deletes an email account -// @Tags Admin - cPanel -// @Accept json -// @Produce json -// @Param email path string true "Email address" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Bad Request" -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/admin/cpanel/emails/{email} [delete] -func (h *Handler) DeleteEmail(w http.ResponseWriter, r *http.Request) { - email := r.PathValue("email") - if email == "" { - http.Error(w, "Email is required", http.StatusBadRequest) - return - } - - if err := h.client.DeleteEmail(email); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "message": "Email deleted successfully", - }) -} - -// ChangePassword godoc -// @Summary Change Email Password -// @Description Changes the password for an email account -// @Tags Admin - cPanel -// @Accept json -// @Produce json -// @Param email path string true "Email address" -// @Param body body ChangePasswordRequest true "New password" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Bad Request" -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/admin/cpanel/emails/{email}/password [put] -func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) { - email := r.PathValue("email") - if email == "" { - http.Error(w, "Email is required", http.StatusBadRequest) - return - } - - var req ChangePasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.Password == "" { - http.Error(w, "Password is required", http.StatusBadRequest) - return - } - - if err := h.client.ChangePassword(email, req.Password); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "message": "Password changed successfully", - }) -} - -// UpdateQuota godoc -// @Summary Update Email Quota -// @Description Updates the disk quota for an email account -// @Tags Admin - cPanel -// @Accept json -// @Produce json -// @Param email path string true "Email address" -// @Param body body UpdateQuotaRequest true "New quota in MB" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Bad Request" -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/admin/cpanel/emails/{email}/quota [put] -func (h *Handler) UpdateQuota(w http.ResponseWriter, r *http.Request) { - email := r.PathValue("email") - if email == "" { - http.Error(w, "Email is required", http.StatusBadRequest) - return - } - - var req UpdateQuotaRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if err := h.client.UpdateQuota(email, req.Quota); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "message": "Quota updated successfully", - }) -} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index cfc48c5..b237b39 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -22,8 +22,6 @@ import ( legacyMiddleware "github.com/rede5/gohorsejobs/backend/internal/middleware" // Admin Imports - "github.com/rede5/gohorsejobs/backend/internal/admin/cloudflare" - "github.com/rede5/gohorsejobs/backend/internal/admin/cpanel" _ "github.com/rede5/gohorsejobs/backend/docs" // Import generated docs httpSwagger "github.com/swaggo/http-swagger/v2" @@ -66,10 +64,6 @@ func NewRouter() http.Handler { jobHandler := handlers.NewJobHandler(jobService) applicationHandler := handlers.NewApplicationHandler(applicationService) - // Initialize Admin Handlers - cloudflareHandler := cloudflare.NewHandler() - cpanelHandler := cpanel.NewHandler() - // cachedPublicIP stores the public IP to avoid repeated external calls var cachedPublicIP string @@ -162,23 +156,6 @@ func NewRouter() http.Handler { log.Println("S3 storage routes registered successfully") } - // --- ADMIN ROUTES (Protected - SuperAdmin only) --- - // Cloudflare Cache Management - mux.Handle("GET /api/v1/admin/cloudflare/zones", authMiddleware.HeaderAuthGuard(http.HandlerFunc(cloudflareHandler.GetZones))) - mux.Handle("POST /api/v1/admin/cloudflare/cache/purge-all", authMiddleware.HeaderAuthGuard(http.HandlerFunc(cloudflareHandler.PurgeAll))) - mux.Handle("POST /api/v1/admin/cloudflare/cache/purge-urls", authMiddleware.HeaderAuthGuard(http.HandlerFunc(cloudflareHandler.PurgeByURLs))) - mux.Handle("POST /api/v1/admin/cloudflare/cache/purge-tags", authMiddleware.HeaderAuthGuard(http.HandlerFunc(cloudflareHandler.PurgeByTags))) - mux.Handle("POST /api/v1/admin/cloudflare/cache/purge-hosts", authMiddleware.HeaderAuthGuard(http.HandlerFunc(cloudflareHandler.PurgeByHosts))) - - // cPanel Email Management - mux.Handle("GET /api/v1/admin/cpanel/emails", authMiddleware.HeaderAuthGuard(http.HandlerFunc(cpanelHandler.ListEmails))) - mux.Handle("POST /api/v1/admin/cpanel/emails", authMiddleware.HeaderAuthGuard(http.HandlerFunc(cpanelHandler.CreateEmail))) - mux.Handle("DELETE /api/v1/admin/cpanel/emails/{email}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(cpanelHandler.DeleteEmail))) - mux.Handle("PUT /api/v1/admin/cpanel/emails/{email}/password", authMiddleware.HeaderAuthGuard(http.HandlerFunc(cpanelHandler.ChangePassword))) - mux.Handle("PUT /api/v1/admin/cpanel/emails/{email}/quota", authMiddleware.HeaderAuthGuard(http.HandlerFunc(cpanelHandler.UpdateQuota))) - - log.Println("Admin routes (Cloudflare, cPanel) registered successfully") - // Swagger Route - available at /docs mux.HandleFunc("/docs/", httpSwagger.WrapHandler) diff --git a/backoffice/.env.example b/backoffice/.env.example index 40cae82..2a3ffbb 100644 --- a/backoffice/.env.example +++ b/backoffice/.env.example @@ -12,3 +12,12 @@ DATABASE_URL=postgresql://user:password@localhost:5432/gohorse_backoffice # JWT JWT_SECRET=your-super-secret-jwt-key JWT_EXPIRATION=7d + +# Cloudflare +CLOUDFLARE_API_TOKEN=your-cloudflare-api-token +CLOUDFLARE_ZONE_ID=your-zone-id + +# cPanel +CPANEL_HOST=https://cpanel.yourdomain.com:2083 +CPANEL_USERNAME=your-cpanel-username +CPANEL_API_TOKEN=your-cpanel-api-token diff --git a/backoffice/package-lock.json b/backoffice/package-lock.json index 7a3b762..9ccb8a9 100644 --- a/backoffice/package-lock.json +++ b/backoffice/package-lock.json @@ -9,11 +9,13 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.3", + "axios": "^1.13.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "reflect-metadata": "^0.2.2", @@ -2276,6 +2278,17 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@nestjs/axios": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", + "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "axios": "^1.3.1", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "11.0.14", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.14.tgz", @@ -5041,9 +5054,20 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "peer": true, + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -5750,7 +5774,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -5967,7 +5990,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6161,7 +6183,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6853,6 +6874,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", @@ -6941,7 +6982,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -6958,7 +6998,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6968,7 +7007,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -7238,7 +7276,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9821,6 +9858,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/backoffice/package.json b/backoffice/package.json index e9c994f..466d86e 100644 --- a/backoffice/package.json +++ b/backoffice/package.json @@ -20,11 +20,13 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.3", + "axios": "^1.13.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "reflect-metadata": "^0.2.2", diff --git a/backoffice/src/admin/admin.module.ts b/backoffice/src/admin/admin.module.ts index f9707e6..87d4cfe 100644 --- a/backoffice/src/admin/admin.module.ts +++ b/backoffice/src/admin/admin.module.ts @@ -1,10 +1,14 @@ import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { AdminService } from './admin.service'; import { AdminController } from './admin.controller'; +import { CloudflareService } from './cloudflare.service'; +import { CloudflareController } from './cloudflare.controller'; @Module({ - providers: [AdminService], - controllers: [AdminController], - exports: [AdminService], + imports: [HttpModule], + providers: [AdminService, CloudflareService], + controllers: [AdminController, CloudflareController], + exports: [AdminService, CloudflareService], }) export class AdminModule {} diff --git a/backoffice/src/admin/cloudflare.controller.ts b/backoffice/src/admin/cloudflare.controller.ts new file mode 100644 index 0000000..101e9c6 --- /dev/null +++ b/backoffice/src/admin/cloudflare.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; +import { CloudflareService } from './cloudflare.service'; + +// Add Auth Guard if you have one, e.g. @UseGuards(JwtAuthGuard) +@ApiTags('Cloudflare') +@ApiBearerAuth() +@Controller('admin/cloudflare') +export class CloudflareController { + constructor(private readonly cloudflareService: CloudflareService) {} + + @Get('zones') + @ApiOperation({ summary: 'List Cloudflare Zones' }) + async listZones() { + return this.cloudflareService.listZones(); + } + + @Post('cache/purge-all') + @ApiOperation({ summary: 'Purge All Cache' }) + async purgeAll() { + return this.cloudflareService.purgeCache({ purge_everything: true }); + } + + @Post('cache/purge-urls') + @ApiOperation({ summary: 'Purge Cache by URLs' }) + @ApiBody({ schema: { example: { urls: ['https://example.com/image.png'] } } }) + async purgeUrls(@Body('urls') urls: string[]) { + return this.cloudflareService.purgeCache({ files: urls }); + } + + @Post('cache/purge-tags') + @ApiOperation({ summary: 'Purge Cache by Tags' }) + @ApiBody({ schema: { example: { tags: ['blog-posts'] } } }) + async purgeTags(@Body('tags') tags: string[]) { + return this.cloudflareService.purgeCache({ tags }); + } + + @Post('cache/purge-hosts') + @ApiOperation({ summary: 'Purge Cache by Hosts' }) + @ApiBody({ schema: { example: { hosts: ['api.example.com'] } } }) + async purgeHosts(@Body('hosts') hosts: string[]) { + return this.cloudflareService.purgeCache({ hosts }); + } +} diff --git a/backoffice/src/admin/cloudflare.service.ts b/backoffice/src/admin/cloudflare.service.ts new file mode 100644 index 0000000..2e1a5c3 --- /dev/null +++ b/backoffice/src/admin/cloudflare.service.ts @@ -0,0 +1,79 @@ +import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom, catchError } from 'rxjs'; +import { AxiosError } from 'axios'; + +@Injectable() +export class CloudflareService { + private readonly logger = new Logger(CloudflareService.name); + private readonly apiToken: string; + private readonly zoneId: string; + private readonly baseUrl = 'https://api.cloudflare.com/client/v4'; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + this.apiToken = + this.configService.get('CLOUDFLARE_API_TOKEN') || ''; + this.zoneId = this.configService.get('CLOUDFLARE_ZONE_ID') || ''; + + if (!this.apiToken) { + this.logger.warn('CLOUDFLARE_API_TOKEN is not set'); + } + } + + private getHeaders() { + return { + Authorization: `Bearer ${this.apiToken}`, + 'Content-Type': 'application/json', + }; + } + + async listZones(): Promise { + const url = `${this.baseUrl}/zones`; + const response = await firstValueFrom( + this.httpService.get(url, { headers: this.getHeaders() }).pipe( + catchError((error: AxiosError) => { + this.logger.error(error.response?.data || error.message); + throw new HttpException( + 'Failed to fetch Cloudflare zones', + HttpStatus.BAD_GATEWAY, + ); + }), + ), + ); + return response.data; + } + + async purgeCache(params: { + purge_everything?: boolean; + files?: string[]; + tags?: string[]; + hosts?: string[]; + }): Promise { + if (!this.zoneId) { + throw new HttpException( + 'CLOUDFLARE_ZONE_ID is not configured', + HttpStatus.BAD_REQUEST, + ); + } + + const url = `${this.baseUrl}/zones/${this.zoneId}/purge_cache`; + + const response = await firstValueFrom( + this.httpService.post(url, params, { headers: this.getHeaders() }).pipe( + catchError((error: AxiosError) => { + this.logger.error(error.response?.data || error.message); + throw new HttpException( + 'Failed to purge Cloudflare cache', + HttpStatus.BAD_GATEWAY, + ); + }), + ), + ); + + return response.data; + } +}