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,73 @@
package gateway
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
var (
ErrInvalidLoginRequest = errors.New("invalid login request")
ErrMissingEmail = errors.New("email is required")
ErrInvalidEmail = errors.New("invalid email format")
ErrMissingPassword = errors.New("password is required")
)
// LoginRequestDTO represents the login request payload
type LoginRequestDTO struct {
Email string `json:"email"`
Password string `json:"password"`
}
// Validate validates the login request
// CWE-20: Improper Input Validation - Validates email format before authentication
func (dto *LoginRequestDTO) Validate() error {
// Validate email format
validator := validation.NewValidator()
if err := validator.ValidateEmail(dto.Email, "email"); err != nil {
return ErrInvalidEmail
}
// Normalize email (lowercase, trim whitespace)
dto.Email = strings.ToLower(strings.TrimSpace(dto.Email))
// Validate password (non-empty)
if strings.TrimSpace(dto.Password) == "" {
return ErrMissingPassword
}
return nil
}
// ParseLoginRequest parses and validates a login request from HTTP request body
func ParseLoginRequest(r *http.Request) (*LoginRequestDTO, error) {
// CWE-436: Validate Content-Type before parsing
if err := httpvalidation.RequireJSONContentType(r); err != nil {
return nil, err
}
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, ErrInvalidLoginRequest
}
defer r.Body.Close()
// Parse JSON
var dto LoginRequestDTO
if err := json.Unmarshal(body, &dto); err != nil {
return nil, ErrInvalidLoginRequest
}
// Validate
if err := dto.Validate(); err != nil {
return nil, err
}
return &dto, nil
}

View file

@ -0,0 +1,63 @@
package gateway
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
)
var (
ErrInvalidRefreshRequest = errors.New("invalid refresh token request")
ErrMissingRefreshToken = errors.New("refresh token is required")
)
// RefreshTokenRequestDTO represents the refresh token request payload
type RefreshTokenRequestDTO struct {
RefreshToken string `json:"refresh_token"`
}
// Validate validates the refresh token request
// CWE-20: Improper Input Validation - Validates refresh token presence
func (dto *RefreshTokenRequestDTO) Validate() error {
// Validate refresh token (non-empty)
if strings.TrimSpace(dto.RefreshToken) == "" {
return ErrMissingRefreshToken
}
// Normalize token (trim whitespace)
dto.RefreshToken = strings.TrimSpace(dto.RefreshToken)
return nil
}
// ParseRefreshTokenRequest parses and validates a refresh token request from HTTP request body
func ParseRefreshTokenRequest(r *http.Request) (*RefreshTokenRequestDTO, error) {
// CWE-436: Validate Content-Type before parsing
if err := httpvalidation.RequireJSONContentType(r); err != nil {
return nil, err
}
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, ErrInvalidRefreshRequest
}
defer r.Body.Close()
// Parse JSON
var dto RefreshTokenRequestDTO
if err := json.Unmarshal(body, &dto); err != nil {
return nil, ErrInvalidRefreshRequest
}
// Validate
if err := dto.Validate(); err != nil {
return nil, err
}
return &dto, nil
}

View file

@ -0,0 +1,196 @@
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/dto/gateway/register_dto.go
package gateway
import (
"fmt"
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// RegisterRequest is the HTTP request for user registration
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
ConfirmPassword string `json:"confirm_password"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
TenantName string `json:"tenant_name"`
Timezone string `json:"timezone,omitempty"` // Optional: defaults to "UTC" if not provided
// Consent fields
AgreeTermsOfService bool `json:"agree_terms_of_service"`
AgreePromotions bool `json:"agree_promotions"`
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `json:"agree_to_tracking_across_third_party_apps_and_services"`
}
// ValidationErrors represents validation errors in RFC 9457 format
type ValidationErrors struct {
Errors map[string][]string
}
// Error implements the error interface
func (v *ValidationErrors) Error() string {
if len(v.Errors) == 0 {
return ""
}
// For backward compatibility with error logging, format as string
var messages []string
for field, errs := range v.Errors {
for _, err := range errs {
messages = append(messages, fmt.Sprintf("%s: %s", field, err))
}
}
return fmt.Sprintf("validation errors: %v", messages)
}
// Validate validates the registration request fields
// CWE-20: Improper Input Validation - Comprehensive email validation and normalization
// Returns all validation errors grouped together in RFC 9457 format
func (r *RegisterRequest) Validate() error {
v := validation.NewValidator()
emailValidator := validation.NewEmailValidator()
validationErrors := make(map[string][]string)
// Validate and normalize email
normalizedEmail, err := emailValidator.ValidateAndNormalize(r.Email, "email")
if err != nil {
// Extract just the error message without the field name prefix
errMsg := extractErrorMessage(err.Error())
validationErrors["email"] = append(validationErrors["email"], errMsg)
} else {
r.Email = normalizedEmail
}
// Validate password (non-empty, will be validated for strength in use case)
if err := v.ValidateRequired(r.Password, "password"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["password"] = append(validationErrors["password"], errMsg)
} else if err := v.ValidateLength(r.Password, "password", 8, 128); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["password"] = append(validationErrors["password"], errMsg)
}
// Validate confirm password
if err := v.ValidateRequired(r.ConfirmPassword, "confirm_password"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["confirm_password"] = append(validationErrors["confirm_password"], errMsg)
} else if r.Password != r.ConfirmPassword {
// Only check if passwords match if both are provided
validationErrors["confirm_password"] = append(validationErrors["confirm_password"], "Passwords do not match")
}
// Validate first name
firstName, err := v.ValidateAndSanitizeString(r.FirstName, "first_name", 1, 100)
if err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["first_name"] = append(validationErrors["first_name"], errMsg)
} else {
r.FirstName = firstName
if err := v.ValidateNoHTML(r.FirstName, "first_name"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["first_name"] = append(validationErrors["first_name"], errMsg)
}
}
// Validate last name
lastName, err := v.ValidateAndSanitizeString(r.LastName, "last_name", 1, 100)
if err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["last_name"] = append(validationErrors["last_name"], errMsg)
} else {
r.LastName = lastName
if err := v.ValidateNoHTML(r.LastName, "last_name"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["last_name"] = append(validationErrors["last_name"], errMsg)
}
}
// Validate tenant name
tenantName, err := v.ValidateAndSanitizeString(r.TenantName, "tenant_name", 1, 100)
if err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["tenant_name"] = append(validationErrors["tenant_name"], errMsg)
} else {
r.TenantName = tenantName
if err := v.ValidateNoHTML(r.TenantName, "tenant_name"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["tenant_name"] = append(validationErrors["tenant_name"], errMsg)
}
}
// Validate consent: Terms of Service is REQUIRED
if !r.AgreeTermsOfService {
validationErrors["agree_terms_of_service"] = append(validationErrors["agree_terms_of_service"], "Must agree to terms of service")
}
// Note: AgreePromotions and AgreeToTrackingAcrossThirdPartyAppsAndServices
// are optional (defaults to false if not provided)
// Return all errors grouped together in RFC 9457 format
if len(validationErrors) > 0 {
return &ValidationErrors{Errors: validationErrors}
}
return nil
}
// extractErrorMessage extracts the error message after the field name prefix
// Example: "email: invalid email format" -> "Invalid email format"
func extractErrorMessage(fullError string) string {
// Find the colon separator
colonIndex := -1
for i, char := range fullError {
if char == ':' {
colonIndex = i
break
}
}
if colonIndex == -1 {
// No colon found, capitalize first letter and return
if len(fullError) > 0 {
return string(fullError[0]-32) + fullError[1:]
}
return fullError
}
// Extract message after colon and trim spaces
message := fullError[colonIndex+1:]
if len(message) > 0 && message[0] == ' ' {
message = message[1:]
}
// Capitalize first letter
if len(message) > 0 {
firstChar := message[0]
if firstChar >= 'a' && firstChar <= 'z' {
message = string(firstChar-32) + message[1:]
}
}
return message
}
// RegisterResponse is the HTTP response after successful registration
type RegisterResponse struct {
// User details
UserID string `json:"user_id"`
UserEmail string `json:"user_email"`
UserName string `json:"user_name"`
UserRole string `json:"user_role"`
// Tenant details
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
TenantSlug string `json:"tenant_slug"`
// Authentication tokens
SessionID string `json:"session_id"`
AccessToken string `json:"access_token"`
AccessExpiry time.Time `json:"access_expiry"`
RefreshToken string `json:"refresh_token"`
RefreshExpiry time.Time `json:"refresh_expiry"`
CreatedAt time.Time `json:"created_at"`
}

View file

@ -0,0 +1,14 @@
package page
// DeleteRequest represents the delete pages request
type DeleteRequest struct {
PageIDs []string `json:"page_ids"`
}
// DeleteResponse represents the delete pages response
type DeleteResponse struct {
DeletedCount int `json:"deleted_count"`
DeindexedCount int `json:"deindexed_count"`
FailedPages []string `json:"failed_pages,omitempty"`
Message string `json:"message"`
}

View file

@ -0,0 +1,19 @@
package page
// SearchRequest represents the search pages request
type SearchRequest struct {
Query string `json:"query"`
Limit int64 `json:"limit"`
Offset int64 `json:"offset"`
Filter string `json:"filter,omitempty"`
}
// SearchResponse represents the search pages response
type SearchResponse struct {
Hits []map[string]interface{} `json:"hits"`
Query string `json:"query"`
ProcessingTimeMs int64 `json:"processing_time_ms"`
TotalHits int64 `json:"total_hits"`
Limit int64 `json:"limit"`
Offset int64 `json:"offset"`
}

View file

@ -0,0 +1,33 @@
package page
import "time"
// StatusResponse represents the sync status response
type StatusResponse struct {
SiteID string `json:"site_id"`
TotalPages int64 `json:"total_pages"`
PublishedPages int64 `json:"published_pages"`
DraftPages int64 `json:"draft_pages"`
LastSyncedAt time.Time `json:"last_synced_at"`
PagesIndexedMonth int64 `json:"pages_indexed_month"`
SearchRequestsMonth int64 `json:"search_requests_month"`
LastResetAt time.Time `json:"last_reset_at"`
SearchIndexStatus string `json:"search_index_status"`
SearchIndexDocCount int64 `json:"search_index_doc_count"`
}
// PageDetailsResponse represents the page details response
type PageDetailsResponse struct {
PageID string `json:"page_id"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
URL string `json:"url"`
Status string `json:"status"`
PostType string `json:"post_type"`
Author string `json:"author"`
PublishedAt time.Time `json:"published_at"`
ModifiedAt time.Time `json:"modified_at"`
IndexedAt time.Time `json:"indexed_at"`
MeilisearchDocID string `json:"meilisearch_doc_id"`
IsIndexed bool `json:"is_indexed"`
}

View file

@ -0,0 +1,124 @@
package page
import (
"fmt"
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// Allowed page statuses
var AllowedPageStatuses = []string{"publish", "draft", "pending", "private", "trash"}
// Allowed post types
var AllowedPostTypes = []string{"post", "page", "attachment", "custom"}
// SyncPageInput represents a single page to sync in the request
type SyncPageInput struct {
PageID string `json:"page_id"`
Title string `json:"title"`
Content string `json:"content"`
Excerpt string `json:"excerpt"`
URL string `json:"url"`
Status string `json:"status"`
PostType string `json:"post_type"`
Author string `json:"author"`
PublishedAt time.Time `json:"published_at"`
ModifiedAt time.Time `json:"modified_at"`
}
// Validate validates a single page input
func (p *SyncPageInput) Validate() error {
v := validation.NewValidator()
// Validate page ID (required)
if err := v.ValidateRequired(p.PageID, "page_id"); err != nil {
return err
}
if err := v.ValidateLength(p.PageID, "page_id", 1, 255); err != nil {
return err
}
// Validate title
title, err := v.ValidateAndSanitizeString(p.Title, "title", 1, 500)
if err != nil {
return err
}
p.Title = title
// Validate content (optional but has max length if provided)
if p.Content != "" {
if err := v.ValidateLength(p.Content, "content", 0, 1000000); err != nil { // 1MB limit
return err
}
}
// Validate excerpt (optional but has max length if provided)
if p.Excerpt != "" {
if err := v.ValidateLength(p.Excerpt, "excerpt", 0, 1000); err != nil {
return err
}
}
// Validate URL
if err := v.ValidateURL(p.URL, "url"); err != nil {
return err
}
// Validate status (enum)
if err := v.ValidateEnum(p.Status, "status", AllowedPageStatuses); err != nil {
return err
}
// Validate post type (enum)
if err := v.ValidateEnum(p.PostType, "post_type", AllowedPostTypes); err != nil {
return err
}
// Validate author
author, err := v.ValidateAndSanitizeString(p.Author, "author", 1, 255)
if err != nil {
return err
}
p.Author = author
if err := v.ValidateNoHTML(p.Author, "author"); err != nil {
return err
}
return nil
}
// SyncRequest represents the sync pages request
type SyncRequest struct {
Pages []SyncPageInput `json:"pages"`
}
// Validate validates the sync request
func (r *SyncRequest) Validate() error {
// Check pages array is not empty
if len(r.Pages) == 0 {
return fmt.Errorf("pages: array cannot be empty")
}
// Validate maximum number of pages in a single request
if len(r.Pages) > 1000 {
return fmt.Errorf("pages: cannot sync more than 1000 pages at once")
}
// Validate each page
for i, page := range r.Pages {
if err := page.Validate(); err != nil {
return fmt.Errorf("pages[%d]: %w", i, err)
}
}
return nil
}
// SyncResponse represents the sync pages response
type SyncResponse struct {
SyncedCount int `json:"synced_count"`
IndexedCount int `json:"indexed_count"`
FailedPages []string `json:"failed_pages,omitempty"`
Message string `json:"message"`
}

View file

@ -0,0 +1,102 @@
package site
import (
"fmt"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// CreateRequest represents the HTTP request for creating a site
// Note: Domain will be extracted from SiteURL by the backend
type CreateRequest struct {
SiteURL string `json:"site_url"`
}
// ValidationErrors represents validation errors in RFC 9457 format
type ValidationErrors struct {
Errors map[string][]string
}
// Error implements the error interface
func (v *ValidationErrors) Error() string {
if len(v.Errors) == 0 {
return ""
}
// For backward compatibility with error logging, format as string
var messages []string
for field, errs := range v.Errors {
for _, err := range errs {
messages = append(messages, fmt.Sprintf("%s: %s", field, err))
}
}
return fmt.Sprintf("validation errors: %v", messages)
}
// Validate validates the create site request fields
// Returns all validation errors grouped together in RFC 9457 format
func (r *CreateRequest) Validate() error {
v := validation.NewValidator()
validationErrors := make(map[string][]string)
// Validate site URL (required)
if err := v.ValidateURL(r.SiteURL, "site_url"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["site_url"] = append(validationErrors["site_url"], errMsg)
}
// Return all errors grouped together in RFC 9457 format
if len(validationErrors) > 0 {
return &ValidationErrors{Errors: validationErrors}
}
return nil
}
// extractErrorMessage extracts the error message after the field name prefix
// Example: "domain: invalid domain format" -> "Invalid domain format"
func extractErrorMessage(fullError string) string {
// Find the colon separator
colonIndex := -1
for i, char := range fullError {
if char == ':' {
colonIndex = i
break
}
}
if colonIndex == -1 {
// No colon found, capitalize first letter and return
if len(fullError) > 0 {
return string(fullError[0]-32) + fullError[1:]
}
return fullError
}
// Extract message after colon and trim spaces
message := fullError[colonIndex+1:]
if len(message) > 0 && message[0] == ' ' {
message = message[1:]
}
// Capitalize first letter
if len(message) > 0 {
firstChar := message[0]
if firstChar >= 'a' && firstChar <= 'z' {
message = string(firstChar-32) + message[1:]
}
}
return message
}
// CreateResponse represents the HTTP response after creating a site
type CreateResponse struct {
ID string `json:"id"`
Domain string `json:"domain"`
SiteURL string `json:"site_url"`
APIKey string `json:"api_key"` // Only returned once at creation
Status string `json:"status"`
VerificationToken string `json:"verification_token"`
SearchIndexName string `json:"search_index_name"`
VerificationInstructions string `json:"verification_instructions"` // DNS TXT record setup instructions
}

View file

@ -0,0 +1,28 @@
package site
import "time"
// GetResponse represents the HTTP response for getting a site
type GetResponse struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Domain string `json:"domain"`
SiteURL string `json:"site_url"`
APIKeyPrefix string `json:"api_key_prefix"`
APIKeyLastFour string `json:"api_key_last_four"`
Status string `json:"status"`
IsVerified bool `json:"is_verified"`
SearchIndexName string `json:"search_index_name"`
TotalPagesIndexed int64 `json:"total_pages_indexed"`
LastIndexedAt time.Time `json:"last_indexed_at,omitempty"`
PluginVersion string `json:"plugin_version,omitempty"`
StorageUsedBytes int64 `json:"storage_used_bytes"`
SearchRequestsCount int64 `json:"search_requests_count"`
MonthlyPagesIndexed int64 `json:"monthly_pages_indexed"`
LastResetAt time.Time `json:"last_reset_at"`
Language string `json:"language,omitempty"`
Timezone string `json:"timezone,omitempty"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View file

@ -0,0 +1,19 @@
package site
import "time"
// ListResponse represents the HTTP response for listing sites
type ListResponse struct {
Sites []SiteListItem `json:"sites"`
Total int `json:"total"`
}
// SiteListItem represents a site in the list
type SiteListItem struct {
ID string `json:"id"`
Domain string `json:"domain"`
Status string `json:"status"`
IsVerified bool `json:"is_verified"`
TotalPagesIndexed int64 `json:"total_pages_indexed"`
CreatedAt time.Time `json:"created_at"`
}

View file

@ -0,0 +1,10 @@
package site
import "time"
// RotateAPIKeyResponse represents the HTTP response after rotating an API key
type RotateAPIKeyResponse struct {
NewAPIKey string `json:"new_api_key"` // New API key (only returned once)
OldKeyLastFour string `json:"old_key_last_four"`
RotatedAt time.Time `json:"rotated_at"`
}

View file

@ -0,0 +1,53 @@
package tenant
import (
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// CreateRequest represents the HTTP request for creating a tenant
type CreateRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
// Validate validates the create tenant request
// CWE-20: Improper Input Validation
func (r *CreateRequest) Validate() error {
validator := validation.NewValidator()
// Validate name: 3-100 chars, printable, no HTML
if err := validator.ValidateRequired(r.Name, "name"); err != nil {
return err
}
if err := validator.ValidateLength(r.Name, "name", 3, 100); err != nil {
return err
}
if err := validator.ValidatePrintable(r.Name, "name"); err != nil {
return err
}
if err := validator.ValidateNoHTML(r.Name, "name"); err != nil {
return err
}
// Validate slug: uses existing slug validation (lowercase, hyphens, 3-63 chars)
if err := validator.ValidateSlug(r.Slug, "slug"); err != nil {
return err
}
// Sanitize inputs
r.Name = validator.SanitizeString(r.Name)
r.Slug = validator.SanitizeString(r.Slug)
return nil
}
// CreateResponse represents the HTTP response after creating a tenant
type CreateResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}

View file

@ -0,0 +1,13 @@
package tenant
import "time"
// GetResponse represents the HTTP response when retrieving a tenant
type GetResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View file

@ -0,0 +1,18 @@
package user
import "time"
// CreateRequest is the HTTP request for creating a user
type CreateRequest struct {
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
// CreateResponse is the HTTP response after creating a user
type CreateResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}

View file

@ -0,0 +1,12 @@
package user
import "time"
// GetResponse is the HTTP response for getting a user
type GetResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}