Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
44
cloud/maplepress-backend/internal/domain/page/interface.go
Normal file
44
cloud/maplepress-backend/internal/domain/page/interface.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/domain/page/interface.go
|
||||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
)
|
||||
|
||||
// Repository defines the interface for page data operations
|
||||
type Repository interface {
|
||||
// Create inserts a new page
|
||||
Create(ctx context.Context, page *Page) error
|
||||
|
||||
// Update updates an existing page
|
||||
Update(ctx context.Context, page *Page) error
|
||||
|
||||
// Upsert creates or updates a page
|
||||
Upsert(ctx context.Context, page *Page) error
|
||||
|
||||
// GetByID retrieves a page by site_id and page_id
|
||||
GetByID(ctx context.Context, siteID gocql.UUID, pageID string) (*Page, error)
|
||||
|
||||
// GetBySiteID retrieves all pages for a site
|
||||
GetBySiteID(ctx context.Context, siteID gocql.UUID) ([]*Page, error)
|
||||
|
||||
// GetBySiteIDPaginated retrieves pages for a site with pagination
|
||||
GetBySiteIDPaginated(ctx context.Context, siteID gocql.UUID, limit int, pageState []byte) ([]*Page, []byte, error)
|
||||
|
||||
// Delete deletes a page
|
||||
Delete(ctx context.Context, siteID gocql.UUID, pageID string) error
|
||||
|
||||
// DeleteBySiteID deletes all pages for a site
|
||||
DeleteBySiteID(ctx context.Context, siteID gocql.UUID) error
|
||||
|
||||
// DeleteMultiple deletes multiple pages by their IDs
|
||||
DeleteMultiple(ctx context.Context, siteID gocql.UUID, pageIDs []string) error
|
||||
|
||||
// CountBySiteID counts pages for a site
|
||||
CountBySiteID(ctx context.Context, siteID gocql.UUID) (int64, error)
|
||||
|
||||
// Exists checks if a page exists
|
||||
Exists(ctx context.Context, siteID gocql.UUID, pageID string) (bool, error)
|
||||
}
|
||||
132
cloud/maplepress-backend/internal/domain/page/page.go
Normal file
132
cloud/maplepress-backend/internal/domain/page/page.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/domain/page/page.go
|
||||
package page
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
)
|
||||
|
||||
// Page represents a WordPress page/post indexed in the system
|
||||
type Page struct {
|
||||
// Identity
|
||||
SiteID gocql.UUID `json:"site_id"` // Partition key
|
||||
PageID string `json:"page_id"` // Clustering key (WordPress page ID)
|
||||
TenantID gocql.UUID `json:"tenant_id"` // For additional isolation
|
||||
|
||||
// Content
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"` // HTML stripped
|
||||
Excerpt string `json:"excerpt"` // Summary
|
||||
|
||||
URL string `json:"url"` // Canonical URL
|
||||
|
||||
// Metadata
|
||||
Status string `json:"status"` // publish, draft, trash
|
||||
PostType string `json:"post_type"` // page, post
|
||||
Author string `json:"author"`
|
||||
|
||||
// Timestamps
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
IndexedAt time.Time `json:"indexed_at"` // When we indexed it
|
||||
|
||||
// Search
|
||||
MeilisearchDocID string `json:"meilisearch_doc_id"` // ID in Meilisearch index
|
||||
|
||||
// Audit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// CWE-359: IP address tracking for GDPR compliance (90-day expiration)
|
||||
CreatedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
|
||||
CreatedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
|
||||
ModifiedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
|
||||
ModifiedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
|
||||
}
|
||||
|
||||
// Status constants
|
||||
const (
|
||||
StatusPublish = "publish"
|
||||
StatusDraft = "draft"
|
||||
StatusTrash = "trash"
|
||||
)
|
||||
|
||||
// PostType constants
|
||||
const (
|
||||
PostTypePage = "page"
|
||||
PostTypePost = "post"
|
||||
)
|
||||
|
||||
// NewPage creates a new Page entity
|
||||
func NewPage(siteID, tenantID gocql.UUID, pageID string, title, content, excerpt, url, status, postType, author string, publishedAt, modifiedAt time.Time, encryptedIP string) *Page {
|
||||
now := time.Now()
|
||||
|
||||
return &Page{
|
||||
SiteID: siteID,
|
||||
PageID: pageID,
|
||||
TenantID: tenantID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Excerpt: excerpt,
|
||||
URL: url,
|
||||
Status: status,
|
||||
PostType: postType,
|
||||
Author: author,
|
||||
PublishedAt: publishedAt,
|
||||
ModifiedAt: modifiedAt,
|
||||
IndexedAt: now,
|
||||
MeilisearchDocID: "", // Set after indexing in Meilisearch
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
// CWE-359: Encrypted IP address tracking for GDPR compliance
|
||||
CreatedFromIPAddress: encryptedIP,
|
||||
CreatedFromIPTimestamp: now,
|
||||
ModifiedFromIPAddress: encryptedIP,
|
||||
ModifiedFromIPTimestamp: now,
|
||||
}
|
||||
}
|
||||
|
||||
// IsPublished checks if the page is published
|
||||
func (p *Page) IsPublished() bool {
|
||||
return p.Status == StatusPublish
|
||||
}
|
||||
|
||||
// ShouldIndex checks if the page should be indexed in search
|
||||
func (p *Page) ShouldIndex() bool {
|
||||
// Only index published pages
|
||||
return p.IsPublished()
|
||||
}
|
||||
|
||||
// GetMeilisearchID returns the Meilisearch document ID
|
||||
func (p *Page) GetMeilisearchID() string {
|
||||
if p.MeilisearchDocID != "" {
|
||||
return p.MeilisearchDocID
|
||||
}
|
||||
// Use page_id as fallback
|
||||
return p.PageID
|
||||
}
|
||||
|
||||
// SetMeilisearchID sets the Meilisearch document ID
|
||||
func (p *Page) SetMeilisearchID(docID string) {
|
||||
p.MeilisearchDocID = docID
|
||||
p.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// MarkIndexed updates the indexed timestamp
|
||||
func (p *Page) MarkIndexed() {
|
||||
p.IndexedAt = time.Now()
|
||||
p.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Update updates the page content
|
||||
func (p *Page) Update(title, content, excerpt, url, status, author string, modifiedAt time.Time) {
|
||||
p.Title = title
|
||||
p.Content = content
|
||||
p.Excerpt = excerpt
|
||||
p.URL = url
|
||||
p.Status = status
|
||||
p.Author = author
|
||||
p.ModifiedAt = modifiedAt
|
||||
p.UpdatedAt = time.Now()
|
||||
}
|
||||
104
cloud/maplepress-backend/internal/domain/securityevent/entity.go
Normal file
104
cloud/maplepress-backend/internal/domain/securityevent/entity.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/domain/securityevent/entity.go
|
||||
package securityevent
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventType represents the type of security event
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
// Account lockout events
|
||||
EventTypeAccountLocked EventType = "account_locked"
|
||||
EventTypeAccountUnlocked EventType = "account_unlocked"
|
||||
|
||||
// Failed login events
|
||||
EventTypeFailedLogin EventType = "failed_login"
|
||||
EventTypeExcessiveFailedLogin EventType = "excessive_failed_login"
|
||||
|
||||
// Successful events
|
||||
EventTypeSuccessfulLogin EventType = "successful_login"
|
||||
|
||||
// Rate limiting events
|
||||
EventTypeIPRateLimitExceeded EventType = "ip_rate_limit_exceeded"
|
||||
)
|
||||
|
||||
// Severity represents the severity level of the security event
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityLow Severity = "low"
|
||||
SeverityMedium Severity = "medium"
|
||||
SeverityHigh Severity = "high"
|
||||
SeverityCritical Severity = "critical"
|
||||
)
|
||||
|
||||
// SecurityEvent represents a security-related event in the system
|
||||
// CWE-778: Insufficient Logging - Security events must be logged for audit
|
||||
type SecurityEvent struct {
|
||||
// Unique identifier for the event
|
||||
ID string `json:"id"`
|
||||
|
||||
// Type of security event
|
||||
EventType EventType `json:"event_type"`
|
||||
|
||||
// Severity level
|
||||
Severity Severity `json:"severity"`
|
||||
|
||||
// User email (hashed for privacy)
|
||||
EmailHash string `json:"email_hash"`
|
||||
|
||||
// Client IP address
|
||||
ClientIP string `json:"client_ip"`
|
||||
|
||||
// User agent
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
|
||||
// Additional metadata as key-value pairs
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
||||
// Timestamp when the event occurred
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// Message describing the event
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// NewSecurityEvent creates a new security event
|
||||
func NewSecurityEvent(
|
||||
eventType EventType,
|
||||
severity Severity,
|
||||
emailHash string,
|
||||
clientIP string,
|
||||
message string,
|
||||
) *SecurityEvent {
|
||||
return &SecurityEvent{
|
||||
ID: generateEventID(),
|
||||
EventType: eventType,
|
||||
Severity: severity,
|
||||
EmailHash: emailHash,
|
||||
ClientIP: clientIP,
|
||||
Metadata: make(map[string]interface{}),
|
||||
Timestamp: time.Now().UTC(),
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// WithMetadata adds metadata to the security event
|
||||
func (e *SecurityEvent) WithMetadata(key string, value interface{}) *SecurityEvent {
|
||||
e.Metadata[key] = value
|
||||
return e
|
||||
}
|
||||
|
||||
// WithUserAgent sets the user agent
|
||||
func (e *SecurityEvent) WithUserAgent(userAgent string) *SecurityEvent {
|
||||
e.UserAgent = userAgent
|
||||
return e
|
||||
}
|
||||
|
||||
// generateEventID generates a unique event ID
|
||||
func generateEventID() string {
|
||||
// Simple timestamp-based ID (can be replaced with UUID if needed)
|
||||
return time.Now().UTC().Format("20060102150405.000000")
|
||||
}
|
||||
42
cloud/maplepress-backend/internal/domain/session.go
Normal file
42
cloud/maplepress-backend/internal/domain/session.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Session represents a user's authentication session
|
||||
type Session struct {
|
||||
ID string `json:"id"` // Session UUID
|
||||
UserID uint64 `json:"user_id"` // User's ID
|
||||
UserUUID uuid.UUID `json:"user_uuid"` // User's UUID
|
||||
UserEmail string `json:"user_email"` // User's email
|
||||
UserName string `json:"user_name"` // User's full name
|
||||
UserRole string `json:"user_role"` // User's role (admin, user, etc.)
|
||||
TenantID uuid.UUID `json:"tenant_id"` // Tenant ID for multi-tenancy
|
||||
CreatedAt time.Time `json:"created_at"` // When the session was created
|
||||
ExpiresAt time.Time `json:"expires_at"` // When the session expires
|
||||
}
|
||||
|
||||
// NewSession creates a new session
|
||||
func NewSession(userID uint64, userUUID uuid.UUID, userEmail, userName, userRole string, tenantID uuid.UUID, duration time.Duration) *Session {
|
||||
now := time.Now()
|
||||
return &Session{
|
||||
ID: gocql.TimeUUID().String(),
|
||||
UserID: userID,
|
||||
UserUUID: userUUID,
|
||||
UserEmail: userEmail,
|
||||
UserName: userName,
|
||||
UserRole: userRole,
|
||||
TenantID: tenantID,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(duration),
|
||||
}
|
||||
}
|
||||
|
||||
// IsExpired checks if the session has expired
|
||||
func (s *Session) IsExpired() bool {
|
||||
return time.Now().After(s.ExpiresAt)
|
||||
}
|
||||
35
cloud/maplepress-backend/internal/domain/site/errors.go
Normal file
35
cloud/maplepress-backend/internal/domain/site/errors.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package site
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrNotFound is returned when a site is not found
|
||||
ErrNotFound = errors.New("site not found")
|
||||
|
||||
// ErrSiteNotFound is an alias for ErrNotFound
|
||||
ErrSiteNotFound = ErrNotFound
|
||||
|
||||
// ErrDomainAlreadyExists is returned when trying to create a site with a domain that already exists
|
||||
ErrDomainAlreadyExists = errors.New("domain already exists")
|
||||
|
||||
// ErrInvalidAPIKey is returned when API key authentication fails
|
||||
ErrInvalidAPIKey = errors.New("invalid API key")
|
||||
|
||||
// ErrSiteNotActive is returned when trying to perform operations on an inactive site
|
||||
ErrSiteNotActive = errors.New("site is not active")
|
||||
|
||||
// ErrSiteNotVerified is returned when trying to perform operations on an unverified site
|
||||
ErrSiteNotVerified = errors.New("site is not verified")
|
||||
|
||||
// ErrQuotaExceeded is returned when a quota limit is reached
|
||||
ErrQuotaExceeded = errors.New("quota exceeded")
|
||||
|
||||
// ErrStorageQuotaExceeded is returned when storage quota is exceeded
|
||||
ErrStorageQuotaExceeded = errors.New("storage quota exceeded")
|
||||
|
||||
// ErrSearchQuotaExceeded is returned when search quota is exceeded
|
||||
ErrSearchQuotaExceeded = errors.New("search quota exceeded")
|
||||
|
||||
// ErrIndexingQuotaExceeded is returned when indexing quota is exceeded
|
||||
ErrIndexingQuotaExceeded = errors.New("indexing quota exceeded")
|
||||
)
|
||||
45
cloud/maplepress-backend/internal/domain/site/interface.go
Normal file
45
cloud/maplepress-backend/internal/domain/site/interface.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
)
|
||||
|
||||
// Repository defines the interface for site data access
|
||||
type Repository interface {
|
||||
// Create inserts a new site into all Cassandra tables
|
||||
Create(ctx context.Context, site *Site) error
|
||||
|
||||
// GetByID retrieves a site by tenant_id and site_id
|
||||
GetByID(ctx context.Context, tenantID, siteID gocql.UUID) (*Site, error)
|
||||
|
||||
// GetByDomain retrieves a site by domain name
|
||||
GetByDomain(ctx context.Context, domain string) (*Site, error)
|
||||
|
||||
// GetByAPIKeyHash retrieves a site by API key hash (for authentication)
|
||||
GetByAPIKeyHash(ctx context.Context, apiKeyHash string) (*Site, error)
|
||||
|
||||
// ListByTenant retrieves all sites for a tenant (paginated)
|
||||
ListByTenant(ctx context.Context, tenantID gocql.UUID, pageSize int, pageState []byte) ([]*Site, []byte, error)
|
||||
|
||||
// Update updates a site in all Cassandra tables
|
||||
Update(ctx context.Context, site *Site) error
|
||||
|
||||
// UpdateAPIKey updates the API key for a site (handles sites_by_apikey table correctly)
|
||||
// Must provide both old and new API key hashes to properly delete old entry and insert new one
|
||||
UpdateAPIKey(ctx context.Context, site *Site, oldAPIKeyHash string) error
|
||||
|
||||
// Delete removes a site from all Cassandra tables
|
||||
Delete(ctx context.Context, tenantID, siteID gocql.UUID) error
|
||||
|
||||
// DomainExists checks if a domain is already registered
|
||||
DomainExists(ctx context.Context, domain string) (bool, error)
|
||||
|
||||
// UpdateUsage updates only usage tracking fields (optimized for frequent updates)
|
||||
UpdateUsage(ctx context.Context, site *Site) error
|
||||
|
||||
// GetAllSitesForUsageReset retrieves all sites for monthly usage counter reset
|
||||
// This uses ALLOW FILTERING and should only be used for administrative tasks
|
||||
GetAllSitesForUsageReset(ctx context.Context, pageSize int, pageState []byte) ([]*Site, []byte, error)
|
||||
}
|
||||
187
cloud/maplepress-backend/internal/domain/site/site.go
Normal file
187
cloud/maplepress-backend/internal/domain/site/site.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// File Path: monorepo/cloud/maplepress-backend/internal/domain/site/site.go
|
||||
package site
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
)
|
||||
|
||||
// Site represents a WordPress site registered in the system
|
||||
type Site struct {
|
||||
// Core Identity
|
||||
ID gocql.UUID `json:"id"`
|
||||
TenantID gocql.UUID `json:"tenant_id"`
|
||||
|
||||
// Site Information
|
||||
SiteURL string `json:"site_url"` // Full URL: https://example.com
|
||||
Domain string `json:"domain"` // Extracted: example.com
|
||||
|
||||
// Authentication
|
||||
APIKeyHash string `json:"-"` // SHA-256 hash, never exposed in JSON
|
||||
APIKeyPrefix string `json:"api_key_prefix"` // "live_sk_a1b2" for display
|
||||
APIKeyLastFour string `json:"api_key_last_four"` // Last 4 chars for display
|
||||
|
||||
// Status & Verification
|
||||
Status string `json:"status"` // active, inactive, pending, suspended, archived
|
||||
IsVerified bool `json:"is_verified"`
|
||||
VerificationToken string `json:"-"` // Never exposed
|
||||
|
||||
// Search & Indexing
|
||||
SearchIndexName string `json:"search_index_name"`
|
||||
TotalPagesIndexed int64 `json:"total_pages_indexed"` // All-time total for stats
|
||||
LastIndexedAt time.Time `json:"last_indexed_at,omitempty"`
|
||||
|
||||
// Plugin Info
|
||||
PluginVersion string `json:"plugin_version,omitempty"`
|
||||
|
||||
// Usage Tracking (for billing) - no quotas/limits
|
||||
StorageUsedBytes int64 `json:"storage_used_bytes"` // Current storage usage
|
||||
SearchRequestsCount int64 `json:"search_requests_count"` // Monthly search count
|
||||
MonthlyPagesIndexed int64 `json:"monthly_pages_indexed"` // Monthly indexing count
|
||||
LastResetAt time.Time `json:"last_reset_at"` // Last monthly reset
|
||||
|
||||
// Metadata (optional fields)
|
||||
Language string `json:"language,omitempty"` // ISO 639-1
|
||||
Timezone string `json:"timezone,omitempty"` // IANA timezone
|
||||
Notes string `json:"notes,omitempty"`
|
||||
|
||||
// Audit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// CWE-359: IP address tracking for GDPR compliance (90-day expiration)
|
||||
CreatedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
|
||||
CreatedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
|
||||
ModifiedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
|
||||
ModifiedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
|
||||
}
|
||||
|
||||
// Status constants
|
||||
const (
|
||||
StatusPending = "pending" // Site created, awaiting verification
|
||||
StatusActive = "active" // Site verified and operational
|
||||
StatusInactive = "inactive" // User temporarily disabled
|
||||
StatusSuspended = "suspended" // Suspended due to violation or non-payment
|
||||
StatusArchived = "archived" // Soft deleted
|
||||
)
|
||||
|
||||
|
||||
// NewSite creates a new Site entity with defaults
|
||||
func NewSite(tenantID gocql.UUID, domain, siteURL string, apiKeyHash, apiKeyPrefix, apiKeyLastFour string, encryptedIP string) *Site {
|
||||
now := time.Now()
|
||||
siteID := gocql.TimeUUID()
|
||||
|
||||
return &Site{
|
||||
ID: siteID,
|
||||
TenantID: tenantID,
|
||||
Domain: domain,
|
||||
SiteURL: siteURL,
|
||||
APIKeyHash: apiKeyHash,
|
||||
APIKeyPrefix: apiKeyPrefix,
|
||||
APIKeyLastFour: apiKeyLastFour,
|
||||
Status: StatusPending,
|
||||
IsVerified: false,
|
||||
VerificationToken: "", // Set by caller
|
||||
SearchIndexName: "site_" + siteID.String(),
|
||||
TotalPagesIndexed: 0,
|
||||
PluginVersion: "",
|
||||
|
||||
// Usage tracking (no quotas/limits)
|
||||
StorageUsedBytes: 0,
|
||||
SearchRequestsCount: 0,
|
||||
MonthlyPagesIndexed: 0,
|
||||
LastResetAt: now,
|
||||
|
||||
Language: "",
|
||||
Timezone: "",
|
||||
Notes: "",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
|
||||
// CWE-359: Encrypted IP address tracking for GDPR compliance
|
||||
CreatedFromIPAddress: encryptedIP,
|
||||
CreatedFromIPTimestamp: now,
|
||||
ModifiedFromIPAddress: encryptedIP,
|
||||
ModifiedFromIPTimestamp: now,
|
||||
}
|
||||
}
|
||||
|
||||
// IsActive checks if the site is active and verified
|
||||
func (s *Site) IsActive() bool {
|
||||
return s.Status == StatusActive && s.IsVerified
|
||||
}
|
||||
|
||||
// IsTestMode checks if the site is using a test API key
|
||||
func (s *Site) IsTestMode() bool {
|
||||
// Check if API key prefix starts with "test_sk_"
|
||||
return len(s.APIKeyPrefix) >= 7 && s.APIKeyPrefix[:7] == "test_sk"
|
||||
}
|
||||
|
||||
// RequiresVerification checks if the site requires verification
|
||||
// Test mode sites skip verification for development
|
||||
func (s *Site) RequiresVerification() bool {
|
||||
return !s.IsTestMode()
|
||||
}
|
||||
|
||||
// CanAccessAPI checks if the site can access the API
|
||||
// More lenient than IsActive - allows pending sites for initial setup
|
||||
func (s *Site) CanAccessAPI() bool {
|
||||
// Allow active sites (fully verified)
|
||||
if s.Status == StatusActive {
|
||||
return true
|
||||
}
|
||||
// Allow pending sites (waiting for verification) for initial setup
|
||||
if s.Status == StatusPending {
|
||||
return true
|
||||
}
|
||||
// Block inactive, suspended, or archived sites
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// IncrementSearchCount increments the search request counter
|
||||
func (s *Site) IncrementSearchCount() {
|
||||
s.SearchRequestsCount++
|
||||
s.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// IncrementPageCount increments the indexed page counter (lifetime total)
|
||||
func (s *Site) IncrementPageCount() {
|
||||
s.TotalPagesIndexed++
|
||||
s.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// IncrementMonthlyPageCount increments both lifetime and monthly page counters
|
||||
func (s *Site) IncrementMonthlyPageCount(count int64) {
|
||||
s.TotalPagesIndexed += count
|
||||
s.MonthlyPagesIndexed += count
|
||||
s.LastIndexedAt = time.Now()
|
||||
s.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// UpdateStorageUsed updates the storage usage
|
||||
func (s *Site) UpdateStorageUsed(bytes int64) {
|
||||
s.StorageUsedBytes = bytes
|
||||
s.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Verify marks the site as verified
|
||||
func (s *Site) Verify() {
|
||||
s.IsVerified = true
|
||||
s.Status = StatusActive
|
||||
s.VerificationToken = "" // Clear token after verification
|
||||
s.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// ResetMonthlyUsage resets monthly usage counters for billing cycles
|
||||
func (s *Site) ResetMonthlyUsage() {
|
||||
now := time.Now()
|
||||
|
||||
// Reset usage counters (no quotas)
|
||||
s.SearchRequestsCount = 0
|
||||
s.MonthlyPagesIndexed = 0
|
||||
s.LastResetAt = now
|
||||
|
||||
s.UpdatedAt = now
|
||||
}
|
||||
75
cloud/maplepress-backend/internal/domain/tenant/entity.go
Normal file
75
cloud/maplepress-backend/internal/domain/tenant/entity.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNameRequired = errors.New("tenant name is required")
|
||||
ErrNameTooShort = errors.New("tenant name must be at least 2 characters")
|
||||
ErrNameTooLong = errors.New("tenant name must not exceed 100 characters")
|
||||
ErrSlugRequired = errors.New("tenant slug is required")
|
||||
ErrSlugInvalid = errors.New("tenant slug must contain only lowercase letters, numbers, and hyphens")
|
||||
ErrTenantNotFound = errors.New("tenant not found")
|
||||
ErrTenantExists = errors.New("tenant already exists")
|
||||
ErrTenantInactive = errors.New("tenant is inactive")
|
||||
)
|
||||
|
||||
// Status represents the tenant's current status
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusInactive Status = "inactive"
|
||||
StatusSuspended Status = "suspended"
|
||||
)
|
||||
|
||||
// Tenant represents a tenant in the system
|
||||
// Each tenant is a separate customer/organization
|
||||
type Tenant struct {
|
||||
ID string
|
||||
Name string // Display name (e.g., "Acme Corporation")
|
||||
Slug string // URL-friendly identifier (e.g., "acme-corp")
|
||||
Status Status
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
// CWE-359: IP address tracking for GDPR compliance (90-day expiration)
|
||||
CreatedFromIPAddress string // Encrypted IP address
|
||||
CreatedFromIPTimestamp time.Time // For 90-day expiration tracking
|
||||
ModifiedFromIPAddress string // Encrypted IP address
|
||||
ModifiedFromIPTimestamp time.Time // For 90-day expiration tracking
|
||||
}
|
||||
|
||||
var slugRegex = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
// Validate validates the tenant entity
|
||||
func (t *Tenant) Validate() error {
|
||||
// Name validation
|
||||
if t.Name == "" {
|
||||
return ErrNameRequired
|
||||
}
|
||||
if len(t.Name) < 2 {
|
||||
return ErrNameTooShort
|
||||
}
|
||||
if len(t.Name) > 100 {
|
||||
return ErrNameTooLong
|
||||
}
|
||||
|
||||
// Slug validation
|
||||
if t.Slug == "" {
|
||||
return ErrSlugRequired
|
||||
}
|
||||
if !slugRegex.MatchString(t.Slug) {
|
||||
return ErrSlugInvalid
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActive returns true if the tenant is active
|
||||
func (t *Tenant) IsActive() bool {
|
||||
return t.Status == StatusActive
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package tenant
|
||||
|
||||
import "context"
|
||||
|
||||
// Repository defines data access for tenants
|
||||
// Note: Tenant operations do NOT require tenantID parameter since
|
||||
// tenants are the top-level entity in our multi-tenant architecture
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, tenant *Tenant) error
|
||||
GetByID(ctx context.Context, id string) (*Tenant, error)
|
||||
GetBySlug(ctx context.Context, slug string) (*Tenant, error)
|
||||
Update(ctx context.Context, tenant *Tenant) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
List(ctx context.Context, limit int) ([]*Tenant, error)
|
||||
ListByStatus(ctx context.Context, status Status, limit int) ([]*Tenant, error)
|
||||
}
|
||||
169
cloud/maplepress-backend/internal/domain/user/entity.go
Normal file
169
cloud/maplepress-backend/internal/domain/user/entity.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents a user entity in the domain
|
||||
// Every user strictly belongs to a tenant
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
Name string
|
||||
LexicalName string
|
||||
Timezone string
|
||||
|
||||
// Role management
|
||||
Role int
|
||||
|
||||
// State management
|
||||
Status int
|
||||
|
||||
// Embedded structs for better organization
|
||||
ProfileData *UserProfileData
|
||||
|
||||
// Encapsulating security related data
|
||||
SecurityData *UserSecurityData
|
||||
|
||||
// Metadata about the user
|
||||
Metadata *UserMetadata
|
||||
|
||||
// Limited metadata fields used for querying
|
||||
TenantID string // Every user belongs to a tenant
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UserProfileData contains user profile information
|
||||
type UserProfileData struct {
|
||||
Phone string
|
||||
Country string
|
||||
Region string
|
||||
City string
|
||||
PostalCode string
|
||||
AddressLine1 string
|
||||
AddressLine2 string
|
||||
HasShippingAddress bool
|
||||
ShippingName string
|
||||
ShippingPhone string
|
||||
ShippingCountry string
|
||||
ShippingRegion string
|
||||
ShippingCity string
|
||||
ShippingPostalCode string
|
||||
ShippingAddressLine1 string
|
||||
ShippingAddressLine2 string
|
||||
Timezone string
|
||||
AgreeTermsOfService bool
|
||||
AgreePromotions bool
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices bool
|
||||
}
|
||||
|
||||
// UserMetadata contains audit and tracking information
|
||||
type UserMetadata struct {
|
||||
// CWE-359: Encrypted IP addresses for GDPR compliance
|
||||
CreatedFromIPAddress string // Encrypted with go-ipcrypt
|
||||
CreatedFromIPTimestamp time.Time // For 90-day expiration tracking
|
||||
CreatedByUserID string
|
||||
CreatedAt time.Time
|
||||
CreatedByName string
|
||||
ModifiedFromIPAddress string // Encrypted with go-ipcrypt
|
||||
ModifiedFromIPTimestamp time.Time // For 90-day expiration tracking
|
||||
ModifiedByUserID string
|
||||
ModifiedAt time.Time
|
||||
ModifiedByName string
|
||||
LastLoginAt time.Time
|
||||
}
|
||||
|
||||
// FullName returns the user's full name computed from FirstName and LastName
|
||||
func (u *User) FullName() string {
|
||||
if u.FirstName == "" && u.LastName == "" {
|
||||
return u.Name // Fallback to Name field if first/last are empty
|
||||
}
|
||||
return u.FirstName + " " + u.LastName
|
||||
}
|
||||
|
||||
// UserSecurityData contains security-related information
|
||||
type UserSecurityData struct {
|
||||
PasswordHashAlgorithm string
|
||||
PasswordHash string
|
||||
|
||||
WasEmailVerified bool
|
||||
Code string
|
||||
CodeType string // 'email_verification' or 'password_reset'
|
||||
CodeExpiry time.Time
|
||||
|
||||
// OTPEnabled controls whether we force 2FA or not during login
|
||||
OTPEnabled bool
|
||||
|
||||
// OTPVerified indicates user has successfully validated their OTP token after enabling 2FA
|
||||
OTPVerified bool
|
||||
|
||||
// OTPValidated automatically gets set as `false` on successful login and then sets `true` once successfully validated by 2FA
|
||||
OTPValidated bool
|
||||
|
||||
// OTPSecret the unique one-time password secret to be shared between our backend and 2FA authenticator apps
|
||||
OTPSecret string
|
||||
|
||||
// OTPAuthURL is the URL used to share
|
||||
OTPAuthURL string
|
||||
|
||||
// OTPBackupCodeHash is the one-time use backup code which resets the 2FA settings
|
||||
OTPBackupCodeHash string
|
||||
|
||||
// OTPBackupCodeHashAlgorithm tracks the hashing algorithm used
|
||||
OTPBackupCodeHashAlgorithm string
|
||||
}
|
||||
|
||||
// Domain errors
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrInvalidEmail = errors.New("invalid email format")
|
||||
ErrEmailRequired = errors.New("email is required")
|
||||
ErrFirstNameRequired = errors.New("first name is required")
|
||||
ErrLastNameRequired = errors.New("last name is required")
|
||||
ErrNameRequired = errors.New("name is required")
|
||||
ErrTenantIDRequired = errors.New("tenant ID is required")
|
||||
ErrPasswordRequired = errors.New("password is required")
|
||||
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
|
||||
ErrPasswordTooWeak = errors.New("password must contain uppercase, lowercase, number, and special character")
|
||||
ErrRoleRequired = errors.New("role is required")
|
||||
ErrUserAlreadyExists = errors.New("user already exists")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrTermsOfServiceRequired = errors.New("must agree to terms of service")
|
||||
)
|
||||
|
||||
// Email validation regex (basic)
|
||||
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// Validate validates the user entity
|
||||
func (u *User) Validate() error {
|
||||
if u.TenantID == "" {
|
||||
return ErrTenantIDRequired
|
||||
}
|
||||
|
||||
if u.Email == "" {
|
||||
return ErrEmailRequired
|
||||
}
|
||||
|
||||
if !emailRegex.MatchString(u.Email) {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
|
||||
if u.Name == "" {
|
||||
return ErrNameRequired
|
||||
}
|
||||
|
||||
// Validate ProfileData if present
|
||||
if u.ProfileData != nil {
|
||||
// Terms of Service is REQUIRED
|
||||
if !u.ProfileData.AgreeTermsOfService {
|
||||
return ErrTermsOfServiceRequired
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
29
cloud/maplepress-backend/internal/domain/user/repository.go
Normal file
29
cloud/maplepress-backend/internal/domain/user/repository.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package user
|
||||
|
||||
import "context"
|
||||
|
||||
// Repository defines the interface for user data access
|
||||
// All methods require tenantID for multi-tenant isolation
|
||||
type Repository interface {
|
||||
// Create creates a new user
|
||||
Create(ctx context.Context, tenantID string, user *User) error
|
||||
|
||||
// GetByID retrieves a user by ID
|
||||
GetByID(ctx context.Context, tenantID string, id string) (*User, error)
|
||||
|
||||
// GetByEmail retrieves a user by email within a specific tenant
|
||||
GetByEmail(ctx context.Context, tenantID string, email string) (*User, error)
|
||||
|
||||
// GetByEmailGlobal retrieves a user by email across all tenants (for login)
|
||||
// This should only be used for authentication where tenant is not yet known
|
||||
GetByEmailGlobal(ctx context.Context, email string) (*User, error)
|
||||
|
||||
// Update updates an existing user
|
||||
Update(ctx context.Context, tenantID string, user *User) error
|
||||
|
||||
// Delete deletes a user by ID
|
||||
Delete(ctx context.Context, tenantID string, id string) error
|
||||
|
||||
// ListByDate lists users created within a date range
|
||||
ListByDate(ctx context.Context, tenantID string, startDate, endDate string, limit int) ([]*User, error)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue