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,157 @@
package site
import (
"encoding/json"
"net/http"
"net/url"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/dns"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
)
// CreateHandler handles site creation HTTP requests
type CreateHandler struct {
service siteservice.CreateSiteService
config *config.Config
logger *zap.Logger
}
// ProvideCreateHandler creates a new CreateHandler
func ProvideCreateHandler(service siteservice.CreateSiteService, cfg *config.Config, logger *zap.Logger) *CreateHandler {
return &CreateHandler{
service: service,
config: cfg,
logger: logger,
}
}
// Handle handles the HTTP request for creating a site
// Requires JWT authentication and tenant context
func (h *CreateHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context (populated by TenantMiddleware)
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "tenant context required")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid tenant ID")
return
}
// CWE-436: Validate Content-Type before parsing
if err := httpvalidation.ValidateJSONContentType(r); err != nil {
httperror.ProblemBadRequest(w, err.Error())
return
}
// Parse request body
var req sitedto.CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.ProblemBadRequest(w, "invalid request body")
return
}
// CWE-20: Comprehensive input validation
if err := req.Validate(); err != nil {
h.logger.Warn("site creation request validation failed", zap.Error(err))
// Check if it's a structured validation error (RFC 9457 format)
if validationErr, ok := err.(*sitedto.ValidationErrors); ok {
httperror.ValidationError(w, validationErr.Errors, "One or more validation errors occurred")
return
}
// Fallback for non-structured errors
httperror.ProblemBadRequest(w, err.Error())
return
}
// Extract domain from site URL
parsedURL, err := url.Parse(req.SiteURL)
if err != nil {
h.logger.Warn("failed to parse site URL", zap.Error(err), zap.String("site_url", req.SiteURL))
httperror.ValidationError(w, map[string][]string{
"site_url": {"Invalid URL format. Please provide a valid URL (e.g., https://example.com)."},
}, "One or more validation errors occurred")
return
}
domain := parsedURL.Hostname()
if domain == "" {
h.logger.Warn("could not extract domain from site URL", zap.String("site_url", req.SiteURL))
httperror.ValidationError(w, map[string][]string{
"site_url": {"Could not extract domain from URL. Please provide a valid URL with a hostname."},
}, "One or more validation errors occurred")
return
}
// Determine test mode based on environment
testMode := h.config.App.IsTestMode()
h.logger.Info("creating site",
zap.String("domain", domain),
zap.String("site_url", req.SiteURL),
zap.String("environment", h.config.App.Environment),
zap.Bool("test_mode", testMode))
// Map DTO to use case input
input := &siteusecase.CreateSiteInput{
Domain: domain,
SiteURL: req.SiteURL,
TestMode: testMode,
}
// Call service
output, err := h.service.CreateSite(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to create site",
zap.Error(err),
zap.String("domain", domain),
zap.String("site_url", req.SiteURL),
zap.String("tenant_id", tenantID.String()))
// Check for domain already exists error
if err.Error() == "domain already exists" {
httperror.ProblemConflict(w, "This domain is already registered. Each domain can only be registered once.")
return
}
httperror.ProblemInternalServerError(w, "Failed to create site. Please try again later.")
return
}
// Map to response DTO
response := sitedto.CreateResponse{
ID: output.ID,
Domain: output.Domain,
SiteURL: output.SiteURL,
APIKey: output.APIKey, // Only shown once!
Status: output.Status,
VerificationToken: output.VerificationToken,
SearchIndexName: output.SearchIndexName,
VerificationInstructions: dns.GetVerificationInstructions(output.Domain, output.VerificationToken),
}
h.logger.Info("site created successfully",
zap.String("site_id", output.ID),
zap.String("domain", output.Domain),
zap.String("tenant_id", tenantID.String()))
// Write response with pretty JSON
httpresponse.Created(w, response)
}

View file

@ -0,0 +1,82 @@
package site
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// DeleteHandler handles site deletion HTTP requests
type DeleteHandler struct {
service siteservice.DeleteSiteService
logger *zap.Logger
}
// ProvideDeleteHandler creates a new DeleteHandler
func ProvideDeleteHandler(service siteservice.DeleteSiteService, logger *zap.Logger) *DeleteHandler {
return &DeleteHandler{
service: service,
logger: logger,
}
}
// Handle handles the HTTP request for deleting a site
// Requires JWT authentication and tenant context
func (h *DeleteHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
return
}
// Get site ID from path parameter
siteIDStr := r.PathValue("id")
if siteIDStr == "" {
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
return
}
// Validate UUID format
if _, err := gocql.ParseUUID(siteIDStr); err != nil {
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
return
}
// Call service
input := &siteusecase.DeleteSiteInput{SiteID: siteIDStr}
_, err = h.service.DeleteSite(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to delete site",
zap.Error(err),
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
return
}
h.logger.Info("site deleted successfully",
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
// Write response
httpresponse.OK(w, map[string]string{
"message": "site deleted successfully",
"site_id": siteIDStr,
})
}

View file

@ -0,0 +1,101 @@
package site
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// GetHandler handles getting a site by ID
type GetHandler struct {
service siteservice.GetSiteService
logger *zap.Logger
}
// ProvideGetHandler creates a new GetHandler
func ProvideGetHandler(service siteservice.GetSiteService, logger *zap.Logger) *GetHandler {
return &GetHandler{
service: service,
logger: logger,
}
}
// Handle handles the HTTP request for getting a site by ID
// Requires JWT authentication and tenant context
func (h *GetHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
return
}
// Get site ID from path parameter
siteIDStr := r.PathValue("id")
if siteIDStr == "" {
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
return
}
// Validate UUID format
if _, err := gocql.ParseUUID(siteIDStr); err != nil {
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
return
}
// Call service
input := &siteusecase.GetSiteInput{ID: siteIDStr}
output, err := h.service.GetSite(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to get site",
zap.Error(err),
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
return
}
// Map to response DTO
response := sitedto.GetResponse{
ID: output.Site.ID.String(),
TenantID: output.Site.TenantID.String(),
Domain: output.Site.Domain,
SiteURL: output.Site.SiteURL,
APIKeyPrefix: output.Site.APIKeyPrefix,
APIKeyLastFour: output.Site.APIKeyLastFour,
Status: output.Site.Status,
IsVerified: output.Site.IsVerified,
SearchIndexName: output.Site.SearchIndexName,
TotalPagesIndexed: output.Site.TotalPagesIndexed,
LastIndexedAt: output.Site.LastIndexedAt,
PluginVersion: output.Site.PluginVersion,
StorageUsedBytes: output.Site.StorageUsedBytes,
SearchRequestsCount: output.Site.SearchRequestsCount,
MonthlyPagesIndexed: output.Site.MonthlyPagesIndexed,
LastResetAt: output.Site.LastResetAt,
Language: output.Site.Language,
Timezone: output.Site.Timezone,
Notes: output.Site.Notes,
CreatedAt: output.Site.CreatedAt,
UpdatedAt: output.Site.UpdatedAt,
}
// Write response with pretty JSON
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,80 @@
package site
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// ListHandler handles listing sites for a tenant
type ListHandler struct {
service siteservice.ListSitesService
logger *zap.Logger
}
// ProvideListHandler creates a new ListHandler
func ProvideListHandler(service siteservice.ListSitesService, logger *zap.Logger) *ListHandler {
return &ListHandler{
service: service,
logger: logger,
}
}
// Handle handles the HTTP request for listing sites
// Requires JWT authentication and tenant context
func (h *ListHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
return
}
// Call service
input := &siteusecase.ListSitesInput{}
output, err := h.service.ListSites(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to list sites",
zap.Error(err),
zap.String("tenant_id", tenantID.String()))
httperror.ProblemInternalServerError(w, "Failed to retrieve your sites. Please try again later.")
return
}
// Map to response DTO
items := make([]sitedto.SiteListItem, len(output.Sites))
for i, s := range output.Sites {
items[i] = sitedto.SiteListItem{
ID: s.ID.String(),
Domain: s.Domain,
Status: s.Status,
IsVerified: s.IsVerified,
TotalPagesIndexed: s.TotalPagesIndexed,
CreatedAt: s.CreatedAt,
}
}
response := sitedto.ListResponse{
Sites: items,
Total: len(items),
}
// Write response with pretty JSON
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,87 @@
package site
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// RotateAPIKeyHandler handles API key rotation HTTP requests
type RotateAPIKeyHandler struct {
service siteservice.RotateAPIKeyService
logger *zap.Logger
}
// ProvideRotateAPIKeyHandler creates a new RotateAPIKeyHandler
func ProvideRotateAPIKeyHandler(service siteservice.RotateAPIKeyService, logger *zap.Logger) *RotateAPIKeyHandler {
return &RotateAPIKeyHandler{
service: service,
logger: logger,
}
}
// Handle handles the HTTP request for rotating a site's API key
// Requires JWT authentication and tenant context
func (h *RotateAPIKeyHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
return
}
// Get site ID from path parameter
siteIDStr := r.PathValue("id")
if siteIDStr == "" {
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
return
}
// Validate UUID format
if _, err := gocql.ParseUUID(siteIDStr); err != nil {
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
return
}
// Call service
input := &siteusecase.RotateAPIKeyInput{SiteID: siteIDStr}
output, err := h.service.RotateAPIKey(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to rotate API key",
zap.Error(err),
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
return
}
// Map to response DTO
response := sitedto.RotateAPIKeyResponse{
NewAPIKey: output.NewAPIKey, // Only shown once!
OldKeyLastFour: output.OldKeyLastFour,
RotatedAt: output.RotatedAt,
}
h.logger.Info("API key rotated successfully",
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
// Write response
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,139 @@
package site
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// VerifySiteHandler handles site verification HTTP requests
type VerifySiteHandler struct {
service siteservice.VerifySiteService
logger *zap.Logger
}
// ProvideVerifySiteHandler creates a new VerifySiteHandler
func ProvideVerifySiteHandler(service siteservice.VerifySiteService, logger *zap.Logger) *VerifySiteHandler {
return &VerifySiteHandler{
service: service,
logger: logger,
}
}
// VerifyResponse represents the verification response
// No request body needed - verification is done via DNS TXT record
type VerifyResponse struct {
Success bool `json:"success"`
Status string `json:"status"`
Message string `json:"message"`
}
// contains checks if a string contains a substring (helper for error checking)
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// Handle handles the HTTP request for verifying a site
// Requires JWT authentication and tenant context
func (h *VerifySiteHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
return
}
// Get site ID from path parameter
siteIDStr := r.PathValue("id")
if siteIDStr == "" {
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
return
}
// Validate UUID format
siteID, err := gocql.ParseUUID(siteIDStr)
if err != nil {
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
return
}
// No request body needed - DNS verification uses the token stored in the site entity
// Call service with empty input
input := &siteusecase.VerifySiteInput{}
output, err := h.service.VerifySite(r.Context(), tenantID, siteID, input)
if err != nil {
h.logger.Error("failed to verify site",
zap.Error(err),
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
// Check for specific error types
errMsg := err.Error()
if errMsg == "site not found" {
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
return
}
// DNS-related errors
if contains(errMsg, "DNS TXT record not found") {
httperror.ProblemBadRequest(w, "DNS TXT record not found. Please add the verification record to your domain's DNS settings and wait 5-10 minutes for propagation.")
return
}
if contains(errMsg, "DNS lookup timed out") {
httperror.ProblemBadRequest(w, "DNS lookup timed out. Please check that your domain's DNS is properly configured.")
return
}
if contains(errMsg, "domain not found") {
httperror.ProblemBadRequest(w, "Domain not found. Please check that your domain is properly registered and DNS is active.")
return
}
if contains(errMsg, "DNS verification failed") {
httperror.ProblemBadRequest(w, "DNS verification failed. Please check your DNS settings and try again.")
return
}
httperror.ProblemInternalServerError(w, "Failed to verify site. Please try again later.")
return
}
// Map to response
response := VerifyResponse{
Success: output.Success,
Status: output.Status,
Message: output.Message,
}
h.logger.Info("site verified successfully",
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
// Write response
httpresponse.OK(w, response)
}