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,17 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/blockedemail/entity.go
package blockedemail
import (
"time"
"github.com/gocql/gocql"
)
// BlockedEmail represents a blocked email entry for a user
type BlockedEmail struct {
UserID gocql.UUID `json:"user_id"`
BlockedEmail string `json:"blocked_email"`
BlockedUserID gocql.UUID `json:"blocked_user_id"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
}

View file

@ -0,0 +1,29 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/blockedemail/interface.go
package blockedemail
import (
"context"
"github.com/gocql/gocql"
)
// BlockedEmailRepository defines the interface for blocked email data access
type BlockedEmailRepository interface {
// Create adds a new blocked email entry
Create(ctx context.Context, blockedEmail *BlockedEmail) error
// Get retrieves a specific blocked email entry
Get(ctx context.Context, userID gocql.UUID, blockedEmail string) (*BlockedEmail, error)
// List retrieves all blocked emails for a user
List(ctx context.Context, userID gocql.UUID) ([]*BlockedEmail, error)
// Delete removes a blocked email entry
Delete(ctx context.Context, userID gocql.UUID, blockedEmail string) error
// IsBlocked checks if an email is blocked by a user
IsBlocked(ctx context.Context, userID gocql.UUID, email string) (bool, error)
// Count returns the number of blocked emails for a user
Count(ctx context.Context, userID gocql.UUID) (int, error)
}

View file

@ -0,0 +1,24 @@
// monorepo/cloud/backend/internal/maplefile/domain/collection/constants.go
package collection
const (
CollectionTypeFolder = "folder"
CollectionTypeAlbum = "album"
)
const ( // Permission levels
CollectionPermissionReadOnly = "read_only"
CollectionPermissionReadWrite = "read_write"
CollectionPermissionAdmin = "admin"
)
const (
CollectionStateActive = "active"
CollectionStateDeleted = "deleted"
CollectionStateArchived = "archived"
)
const (
CollectionAccessTypeOwner = "owner"
CollectionAccessTypeMember = "member"
)

View file

@ -0,0 +1,43 @@
// monorepo/cloud/backend/internal/maplefile/domain/collection/filter.go
package collection
import "github.com/gocql/gocql"
// CollectionFilterOptions defines the filtering options for retrieving collections
type CollectionFilterOptions struct {
// IncludeOwned includes collections where the user is the owner
IncludeOwned bool `json:"include_owned"`
// IncludeShared includes collections where the user is a member (shared with them)
IncludeShared bool `json:"include_shared"`
// UserID is the user for whom we're filtering collections
UserID gocql.UUID `json:"user_id"`
}
// CollectionFilterResult represents the result of a filtered collection query
type CollectionFilterResult struct {
// OwnedCollections are collections where the user is the owner
OwnedCollections []*Collection `json:"owned_collections"`
// SharedCollections are collections shared with the user
SharedCollections []*Collection `json:"shared_collections"`
// TotalCount is the total number of collections returned
TotalCount int `json:"total_count"`
}
// GetAllCollections returns all collections (owned + shared) in a single slice
func (r *CollectionFilterResult) GetAllCollections() []*Collection {
allCollections := make([]*Collection, 0, len(r.OwnedCollections)+len(r.SharedCollections))
allCollections = append(allCollections, r.OwnedCollections...)
allCollections = append(allCollections, r.SharedCollections...)
return allCollections
}
// IsValid checks if the filter options are valid
func (options *CollectionFilterOptions) IsValid() bool {
// At least one filter option must be enabled
return options.IncludeOwned || options.IncludeShared
}
// ShouldIncludeAll returns true if both owned and shared collections should be included
func (options *CollectionFilterOptions) ShouldIncludeAll() bool {
return options.IncludeOwned && options.IncludeShared
}

View file

@ -0,0 +1,89 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/domain/collection/interface.go
package collection
import (
"context"
"time"
"github.com/gocql/gocql"
)
// CollectionRepository defines the interface for collection persistence operations
type CollectionRepository interface {
// Collection CRUD operations
Create(ctx context.Context, collection *Collection) error
Get(ctx context.Context, id gocql.UUID) (*Collection, error)
Update(ctx context.Context, collection *Collection) error
SoftDelete(ctx context.Context, id gocql.UUID) error // Now soft delete
HardDelete(ctx context.Context, id gocql.UUID) error
// State management operations
Archive(ctx context.Context, id gocql.UUID) error
Restore(ctx context.Context, id gocql.UUID) error
// Hierarchical queries (now state-aware)
FindByParent(ctx context.Context, parentID gocql.UUID) ([]*Collection, error)
FindRootCollections(ctx context.Context, ownerID gocql.UUID) ([]*Collection, error)
FindDescendants(ctx context.Context, collectionID gocql.UUID) ([]*Collection, error)
// GetFullHierarchy(ctx context.Context, rootID gocql.UUID) (*Collection, error) // DEPRECATED AND WILL BE REMOVED
// Move collection to a new parent
MoveCollection(ctx context.Context, collectionID, newParentID gocql.UUID, updatedAncestors []gocql.UUID, updatedPathSegments []string) error
// Collection ownership and access queries (now state-aware)
CheckIfExistsByID(ctx context.Context, id gocql.UUID) (bool, error)
GetAllByUserID(ctx context.Context, ownerID gocql.UUID) ([]*Collection, error)
GetCollectionsSharedWithUser(ctx context.Context, userID gocql.UUID) ([]*Collection, error)
IsCollectionOwner(ctx context.Context, collectionID, userID gocql.UUID) (bool, error)
CheckAccess(ctx context.Context, collectionID, userID gocql.UUID, requiredPermission string) (bool, error)
GetUserPermissionLevel(ctx context.Context, collectionID, userID gocql.UUID) (string, error)
// Filtered collection queries (now state-aware)
GetCollectionsWithFilter(ctx context.Context, options CollectionFilterOptions) (*CollectionFilterResult, error)
// Collection membership operations
AddMember(ctx context.Context, collectionID gocql.UUID, membership *CollectionMembership) error
RemoveMember(ctx context.Context, collectionID, recipientID gocql.UUID) error
RemoveUserFromAllCollections(ctx context.Context, userID gocql.UUID, userEmail string) ([]gocql.UUID, error)
UpdateMemberPermission(ctx context.Context, collectionID, recipientID gocql.UUID, newPermission string) error
GetCollectionMembership(ctx context.Context, collectionID, recipientID gocql.UUID) (*CollectionMembership, error)
// Hierarchical sharing
AddMemberToHierarchy(ctx context.Context, rootID gocql.UUID, membership *CollectionMembership) error
RemoveMemberFromHierarchy(ctx context.Context, rootID, recipientID gocql.UUID) error
// GetCollectionSyncData retrieves collection sync data with pagination for the specified user
GetCollectionSyncData(ctx context.Context, userID gocql.UUID, cursor *CollectionSyncCursor, limit int64) (*CollectionSyncResponse, error)
GetCollectionSyncDataByAccessType(ctx context.Context, userID gocql.UUID, cursor *CollectionSyncCursor, limit int64, accessType string) (*CollectionSyncResponse, error)
// Count operations for all collection types (folders + albums)
CountOwnedCollections(ctx context.Context, userID gocql.UUID) (int, error)
CountSharedCollections(ctx context.Context, userID gocql.UUID) (int, error)
CountOwnedFolders(ctx context.Context, userID gocql.UUID) (int, error)
CountSharedFolders(ctx context.Context, userID gocql.UUID) (int, error)
CountTotalUniqueFolders(ctx context.Context, userID gocql.UUID) (int, error)
// IP Anonymization for GDPR compliance
AnonymizeOldIPs(ctx context.Context, cutoffDate time.Time) (int, error)
AnonymizeCollectionIPsByOwner(ctx context.Context, ownerID gocql.UUID) (int, error) // For GDPR right-to-be-forgotten
// File count maintenance operations
IncrementFileCount(ctx context.Context, collectionID gocql.UUID) error
DecrementFileCount(ctx context.Context, collectionID gocql.UUID) error
// RecalculateAllFileCounts recalculates file_count for all collections
// by counting active files. Used for data migration/repair.
RecalculateAllFileCounts(ctx context.Context) (*RecalculateAllFileCountsResult, error)
// Tag-related operations
// ListByTagID retrieves all collections that have the specified tag assigned
// Used for tag update propagation (updating embedded tag data across all collections)
ListByTagID(ctx context.Context, tagID gocql.UUID) ([]*Collection, error)
}
// RecalculateAllFileCountsResult holds the results of the recalculation operation
type RecalculateAllFileCountsResult struct {
TotalCollections int
UpdatedCount int
ErrorCount int
}

View file

@ -0,0 +1,124 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/domain/collection/model.go
package collection
import (
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
"github.com/gocql/gocql"
)
// Collection represents a folder or album.
// Can be used for both root collections and embedded subcollections
type Collection struct {
// Identifiers
// ID is the unique identifier for the collection in the cloud backend.
ID gocql.UUID `bson:"_id" json:"id"`
// OwnerID is the ID of the user who originally created and owns this collection.
// The owner has administrative privileges by default.
OwnerID gocql.UUID `bson:"owner_id" json:"owner_id"`
// Encryption and Content Details
// EncryptedName is the name of the collection, encrypted using the collection's unique key.
// Stored and transferred in encrypted form.
EncryptedName string `bson:"encrypted_name" json:"encrypted_name"`
// CollectionType indicates the nature of the collection, either "folder" or "album".
// Defined by CollectionTypeFolder and CollectionTypeAlbum constants.
CollectionType string `bson:"collection_type" json:"collection_type"` // "folder" or "album"
// EncryptedCollectionKey is the unique symmetric key used to encrypt the collection's data (like name and file metadata).
// This key is encrypted with the owner's master key for storage and transmission,
// allowing the owner's device to decrypt it using their master key.
EncryptedCollectionKey *crypto.EncryptedCollectionKey `bson:"encrypted_collection_key" json:"encrypted_collection_key"`
// EncryptedCustomIcon stores the custom icon for this collection, encrypted with the collection key.
// Empty string means use default folder/album icon.
// Contains either an emoji character (e.g., "📷") or "icon:<identifier>" for predefined icons.
EncryptedCustomIcon string `bson:"encrypted_custom_icon" json:"encrypted_custom_icon"`
// Sharing
// Collection members (users with access)
Members []CollectionMembership `bson:"members" json:"members"`
// Hierarchical structure fields
// ParentID is the ID of the parent collection if this is a subcollection.
// It is omitted (nil) for root collections. Used to reconstruct the hierarchy.
ParentID gocql.UUID `bson:"parent_id,omitempty" json:"parent_id,omitempty"` // Parent collection ID, not stored for root collections
// AncestorIDs is an array containing the IDs of all parent collections up to the root.
// This field is used for efficient querying and traversal of the collection hierarchy without joins.
AncestorIDs []gocql.UUID `bson:"ancestor_ids,omitempty" json:"ancestor_ids,omitempty"` // Array of ancestor IDs for efficient querying
// File count for performance optimization
// FileCount stores the number of active files in this collection.
// This denormalized field eliminates N+1 queries when listing collections.
FileCount int64 `bson:"file_count" json:"file_count"`
// DEPRECATED: Replaced by Tags field below
// TagIDs []gocql.UUID `bson:"tag_ids,omitempty" json:"tag_ids,omitempty"`
// Tags stores full embedded tag data (eliminates frontend API lookups)
// Stored as JSON text in database, marshaled/unmarshaled automatically
Tags []tag.EmbeddedTag `bson:"tags,omitempty" json:"tags,omitempty"`
// Ownership, timestamps and conflict resolution
// CreatedAt is the timestamp when the collection was initially created.
// Recorded on the local device and synced.
CreatedAt time.Time `bson:"created_at" json:"created_at"`
// CreatedByUserID is the ID of the user who created this file.
CreatedByUserID gocql.UUID `bson:"created_by_user_id" json:"created_by_user_id"`
// ModifiedAt is the timestamp of the last modification to the collection's metadata or content.
// Updated on the local device and synced.
ModifiedAt time.Time `bson:"modified_at" json:"modified_at"`
ModifiedByUserID gocql.UUID `bson:"modified_by_user_id" json:"modified_by_user_id"`
// The current version of the file.
Version uint64 `bson:"version" json:"version"` // Every mutation (create, update, delete, etc) is a versioned operation, keep track of the version number with this variable
// State management
State string `bson:"state" json:"state"` // active, deleted, archived
TombstoneVersion uint64 `bson:"tombstone_version" json:"tombstone_version"` // The `version` number that this collection was deleted at.
TombstoneExpiry time.Time `bson:"tombstone_expiry" json:"tombstone_expiry"`
}
// CollectionMembership represents a user's access to a collection
type CollectionMembership struct {
ID gocql.UUID `bson:"_id" json:"id"`
CollectionID gocql.UUID `bson:"collection_id" json:"collection_id"` // ID of the collection (redundant but helpful for queries)
RecipientID gocql.UUID `bson:"recipient_id" json:"recipient_id"` // User receiving access
RecipientEmail string `bson:"recipient_email" json:"recipient_email"` // Email for display purposes
GrantedByID gocql.UUID `bson:"granted_by_id" json:"granted_by_id"` // User who shared the collection
// Collection key encrypted with recipient's public key using box_seal. This matches the box_seal format which doesn't need a separate nonce.
EncryptedCollectionKey []byte `bson:"encrypted_collection_key" json:"encrypted_collection_key"`
// Access details
PermissionLevel string `bson:"permission_level" json:"permission_level"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
// Sharing origin tracking
IsInherited bool `bson:"is_inherited" json:"is_inherited"` // Tracks whether access was granted directly or inherited from a parent
InheritedFromID gocql.UUID `bson:"inherited_from_id,omitempty" json:"inherited_from_id,omitempty"` // InheritedFromID identifies which parent collection granted this access
}
// CollectionSyncCursor represents cursor-based pagination for sync operations
type CollectionSyncCursor struct {
LastModified time.Time `json:"last_modified" bson:"last_modified"`
LastID gocql.UUID `json:"last_id" bson:"last_id"`
}
// CollectionSyncItem represents minimal collection data for sync operations
type CollectionSyncItem struct {
ID gocql.UUID `json:"id" bson:"_id"`
Version uint64 `json:"version" bson:"version"`
ModifiedAt time.Time `json:"modified_at" bson:"modified_at"`
State string `json:"state" bson:"state"`
ParentID *gocql.UUID `json:"parent_id,omitempty" bson:"parent_id,omitempty"`
TombstoneVersion uint64 `bson:"tombstone_version" json:"tombstone_version"`
TombstoneExpiry time.Time `bson:"tombstone_expiry" json:"tombstone_expiry"`
EncryptedCustomIcon string `json:"encrypted_custom_icon,omitempty" bson:"encrypted_custom_icon,omitempty"`
}
// CollectionSyncResponse represents the response for collection sync data
type CollectionSyncResponse struct {
Collections []CollectionSyncItem `json:"collections"`
NextCursor *CollectionSyncCursor `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}

View file

@ -0,0 +1,37 @@
// monorepo/cloud/backend/internal/maplefile/domain/collection/state_validator.go
package collection
import "errors"
// StateTransition validates collection state transitions
type StateTransition struct {
From string
To string
}
// IsValidStateTransition checks if a state transition is allowed
func IsValidStateTransition(from, to string) error {
validTransitions := map[StateTransition]bool{
// From active
{CollectionStateActive, CollectionStateDeleted}: true,
{CollectionStateActive, CollectionStateArchived}: true,
// From deleted (cannot be restored nor archived)
{CollectionStateDeleted, CollectionStateActive}: false,
{CollectionStateDeleted, CollectionStateArchived}: false,
// From archived (can only be restored to active)
{CollectionStateArchived, CollectionStateActive}: true,
// Same state transitions (no-op)
{CollectionStateActive, CollectionStateActive}: true,
{CollectionStateDeleted, CollectionStateDeleted}: true,
{CollectionStateArchived, CollectionStateArchived}: true,
}
if !validTransitions[StateTransition{from, to}] {
return errors.New("invalid state transition from " + from + " to " + to)
}
return nil
}

View file

@ -0,0 +1,69 @@
// monorepo/cloud/maplefile-backend/internal/domain/crypto/domain/keys/kdf.go
package crypto
import (
"fmt"
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/crypto"
)
// KDFParams stores the key derivation function parameters
type KDFParams struct {
Algorithm string `json:"algorithm" bson:"algorithm"` // "argon2id", "pbkdf2", "scrypt"
Version string `json:"version" bson:"version"` // "1.0", "1.1", etc.
Iterations uint32 `json:"iterations" bson:"iterations"` // For PBKDF2 or Argon2 time cost
Memory uint32 `json:"memory" bson:"memory"` // For Argon2 memory in KB
Parallelism uint8 `json:"parallelism" bson:"parallelism"` // For Argon2 threads
SaltLength uint32 `json:"salt_length" bson:"salt_length"` // Salt size in bytes
KeyLength uint32 `json:"key_length" bson:"key_length"` // Output key size in bytes
}
// DefaultKDFParams returns the current recommended KDF parameters
func DefaultKDFParams() KDFParams {
return KDFParams{
Algorithm: crypto.Argon2IDAlgorithm,
Version: "1.0", // Always starts at 1.0
Iterations: crypto.Argon2OpsLimit, // Time cost
Memory: crypto.Argon2MemLimit,
Parallelism: crypto.Argon2Parallelism,
SaltLength: crypto.Argon2SaltSize,
KeyLength: crypto.Argon2KeySize,
}
}
// Validate checks if KDF parameters are valid
func (k KDFParams) Validate() error {
switch k.Algorithm {
case crypto.Argon2IDAlgorithm:
if k.Iterations < 1 {
return fmt.Errorf("argon2id time cost must be >= 1")
}
if k.Memory < 1024 {
return fmt.Errorf("argon2id memory must be >= 1024 KB")
}
if k.Parallelism < 1 {
return fmt.Errorf("argon2id parallelism must be >= 1")
}
default:
return fmt.Errorf("unsupported KDF algorithm: %s", k.Algorithm)
}
if k.SaltLength < 8 {
return fmt.Errorf("salt length must be >= 8 bytes")
}
if k.KeyLength < 16 {
return fmt.Errorf("key length must be >= 16 bytes")
}
return nil
}
// KDFUpgradePolicy defines when to upgrade KDF parameters
type KDFUpgradePolicy struct {
MinimumParams KDFParams `json:"minimum_params" bson:"minimum_params"`
RecommendedParams KDFParams `json:"recommended_params" bson:"recommended_params"`
MaxPasswordAge time.Duration `json:"max_password_age" bson:"max_password_age"`
UpgradeOnNextLogin bool `json:"upgrade_on_next_login" bson:"upgrade_on_next_login"`
LastUpgradeCheck time.Time `json:"last_upgrade_check" bson:"last_upgrade_check"`
}

View file

@ -0,0 +1,355 @@
package crypto
import (
"encoding/base64"
"encoding/json"
"fmt"
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/crypto"
)
// tryDecodeBase64 attempts to decode a base64 string using multiple encodings.
// It tries URL-safe without padding first (libsodium's URLSAFE_NO_PADDING),
// then standard base64 with padding, then standard without padding.
func tryDecodeBase64(s string) ([]byte, error) {
var lastErr error
// Try URL-safe base64 without padding (libsodium's URLSAFE_NO_PADDING)
if data, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return data, nil
} else {
lastErr = err
}
// Try standard base64 with padding (Go's default for []byte)
if data, err := base64.StdEncoding.DecodeString(s); err == nil {
return data, nil
} else {
lastErr = err
}
// Try standard base64 without padding
if data, err := base64.RawStdEncoding.DecodeString(s); err == nil {
return data, nil
} else {
lastErr = err
}
// Try URL-safe base64 with padding
if data, err := base64.URLEncoding.DecodeString(s); err == nil {
return data, nil
} else {
lastErr = err
}
return nil, fmt.Errorf("failed to decode base64 with any encoding: %w", lastErr)
}
// MasterKey represents the root encryption key for a user
type MasterKey struct {
Key []byte `json:"key" bson:"key"`
}
// EncryptedMasterKey is the master key encrypted with the key encryption key
type EncryptedMasterKey struct {
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
Nonce []byte `json:"nonce" bson:"nonce"`
KeyVersion int `json:"key_version" bson:"key_version"`
RotatedAt *time.Time `json:"rotated_at,omitempty" bson:"rotated_at,omitempty"`
PreviousKeys []EncryptedHistoricalKey `json:"previous_keys,omitempty" bson:"previous_keys,omitempty"`
}
func (emk *EncryptedMasterKey) GetCurrentVersion() int {
return emk.KeyVersion
}
func (emk *EncryptedMasterKey) GetKeyByVersion(version int) *EncryptedHistoricalKey {
if version == emk.KeyVersion {
// Return current key as historical format
return &EncryptedHistoricalKey{
KeyVersion: emk.KeyVersion,
Ciphertext: emk.Ciphertext,
Nonce: emk.Nonce,
Algorithm: crypto.ChaCha20Poly1305Algorithm, // ✅ Updated to ChaCha20-Poly1305
}
}
for _, key := range emk.PreviousKeys {
if key.KeyVersion == version {
return &key
}
}
return nil
}
// KeyEncryptionKey derived from user password
type KeyEncryptionKey struct {
Key []byte `json:"key" bson:"key"`
Salt []byte `json:"salt" bson:"salt"`
}
// PublicKey for asymmetric encryption
type PublicKey struct {
Key []byte `json:"key" bson:"key"`
VerificationID string `json:"verification_id" bson:"verification_id"`
}
// PrivateKey for asymmetric decryption
type PrivateKey struct {
Key []byte `json:"key" bson:"key"`
}
// EncryptedPrivateKey is the private key encrypted with the master key
type EncryptedPrivateKey struct {
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
Nonce []byte `json:"nonce" bson:"nonce"`
}
// RecoveryKey for account recovery
type RecoveryKey struct {
Key []byte `json:"key" bson:"key"`
}
// EncryptedRecoveryKey is the recovery key encrypted with the master key
type EncryptedRecoveryKey struct {
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
Nonce []byte `json:"nonce" bson:"nonce"`
}
// CollectionKey encrypts files in a collection
type CollectionKey struct {
Key []byte `json:"key" bson:"key"`
CollectionID string `json:"collection_id" bson:"collection_id"`
}
// EncryptedCollectionKey is the collection key encrypted with master key
type EncryptedCollectionKey struct {
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
Nonce []byte `json:"nonce" bson:"nonce"`
KeyVersion int `json:"key_version" bson:"key_version"`
RotatedAt *time.Time `json:"rotated_at,omitempty" bson:"rotated_at,omitempty"`
PreviousKeys []EncryptedHistoricalKey `json:"previous_keys,omitempty" bson:"previous_keys,omitempty"`
}
func (eck *EncryptedCollectionKey) NeedsRotation(policy KeyRotationPolicy) bool {
if eck.RotatedAt == nil {
return true // Never rotated
}
keyAge := time.Since(*eck.RotatedAt)
return keyAge > policy.MaxKeyAge
}
// MarshalJSON custom marshaller for EncryptedCollectionKey to serialize bytes as base64 strings.
func (eck *EncryptedCollectionKey) MarshalJSON() ([]byte, error) {
type Alias struct {
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
KeyVersion int `json:"key_version"`
}
alias := Alias{
Ciphertext: base64.StdEncoding.EncodeToString(eck.Ciphertext),
Nonce: base64.StdEncoding.EncodeToString(eck.Nonce),
KeyVersion: eck.KeyVersion,
}
return json.Marshal(alias)
}
// UnmarshalJSON custom unmarshaller for EncryptedCollectionKey to handle URL-safe base64 strings.
func (eck *EncryptedCollectionKey) UnmarshalJSON(data []byte) error {
// Temporary struct to unmarshal into string fields
type Alias struct {
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
KeyVersion int `json:"key_version"`
}
var alias Alias
if err := json.Unmarshal(data, &alias); err != nil {
return fmt.Errorf("failed to unmarshal EncryptedCollectionKey into alias: %w", err)
}
// Set KeyVersion
eck.KeyVersion = alias.KeyVersion
// Decode Ciphertext - try multiple base64 encodings
if alias.Ciphertext != "" {
ciphertextBytes, err := tryDecodeBase64(alias.Ciphertext)
if err != nil {
return fmt.Errorf("failed to decode EncryptedCollectionKey.Ciphertext: %w", err)
}
eck.Ciphertext = ciphertextBytes
}
// Decode Nonce - try multiple base64 encodings
if alias.Nonce != "" {
nonceBytes, err := tryDecodeBase64(alias.Nonce)
if err != nil {
return fmt.Errorf("failed to decode EncryptedCollectionKey.Nonce: %w", err)
}
eck.Nonce = nonceBytes
}
return nil
}
// FileKey encrypts a specific file
type FileKey struct {
Key []byte `json:"key" bson:"key"`
FileID string `json:"file_id" bson:"file_id"`
}
// EncryptedFileKey is the file key encrypted with collection key
type EncryptedFileKey struct {
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
Nonce []byte `json:"nonce" bson:"nonce"`
KeyVersion int `json:"key_version" bson:"key_version"`
RotatedAt *time.Time `json:"rotated_at,omitempty" bson:"rotated_at,omitempty"`
PreviousKeys []EncryptedHistoricalKey `json:"previous_keys,omitempty" bson:"previous_keys,omitempty"`
}
func (eck *EncryptedFileKey) NeedsRotation(policy KeyRotationPolicy) bool {
if eck.RotatedAt == nil {
return true // Never rotated
}
keyAge := time.Since(*eck.RotatedAt)
return keyAge > policy.MaxKeyAge
}
// MarshalJSON custom marshaller for EncryptedFileKey to serialize bytes as base64 strings.
func (efk *EncryptedFileKey) MarshalJSON() ([]byte, error) {
type Alias struct {
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
KeyVersion int `json:"key_version"`
}
alias := Alias{
Ciphertext: base64.StdEncoding.EncodeToString(efk.Ciphertext),
Nonce: base64.StdEncoding.EncodeToString(efk.Nonce),
KeyVersion: efk.KeyVersion,
}
return json.Marshal(alias)
}
// UnmarshalJSON custom unmarshaller for EncryptedFileKey to handle URL-safe base64 strings.
func (efk *EncryptedFileKey) UnmarshalJSON(data []byte) error {
// Temporary struct to unmarshal into string fields
type Alias struct {
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
KeyVersion int `json:"key_version"`
}
var alias Alias
if err := json.Unmarshal(data, &alias); err != nil {
return fmt.Errorf("failed to unmarshal EncryptedFileKey into alias: %w", err)
}
// Set KeyVersion
efk.KeyVersion = alias.KeyVersion
// Decode Ciphertext - try multiple base64 encodings
if alias.Ciphertext != "" {
ciphertextBytes, err := tryDecodeBase64(alias.Ciphertext)
if err != nil {
return fmt.Errorf("failed to decode EncryptedFileKey.Ciphertext: %w", err)
}
efk.Ciphertext = ciphertextBytes
}
// Decode Nonce - try multiple base64 encodings
if alias.Nonce != "" {
nonceBytes, err := tryDecodeBase64(alias.Nonce)
if err != nil {
return fmt.Errorf("failed to decode EncryptedFileKey.Nonce: %w", err)
}
efk.Nonce = nonceBytes
}
return nil
}
// TagKey encrypts tag data (name and color)
type TagKey struct {
Key []byte `json:"key" bson:"key"`
TagID string `json:"tag_id" bson:"tag_id"`
}
// EncryptedTagKey is the tag key encrypted with user's master key
type EncryptedTagKey struct {
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
Nonce []byte `json:"nonce" bson:"nonce"`
KeyVersion int `json:"key_version" bson:"key_version"`
RotatedAt *time.Time `json:"rotated_at,omitempty" bson:"rotated_at,omitempty"`
PreviousKeys []EncryptedHistoricalKey `json:"previous_keys,omitempty" bson:"previous_keys,omitempty"`
}
func (etk *EncryptedTagKey) NeedsRotation(policy KeyRotationPolicy) bool {
if etk.RotatedAt == nil {
return true // Never rotated
}
keyAge := time.Since(*etk.RotatedAt)
return keyAge > policy.MaxKeyAge
}
// MarshalJSON custom marshaller for EncryptedTagKey to serialize bytes as base64 strings.
func (etk *EncryptedTagKey) MarshalJSON() ([]byte, error) {
type Alias struct {
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
KeyVersion int `json:"key_version"`
}
alias := Alias{
Ciphertext: base64.StdEncoding.EncodeToString(etk.Ciphertext),
Nonce: base64.StdEncoding.EncodeToString(etk.Nonce),
KeyVersion: etk.KeyVersion,
}
return json.Marshal(alias)
}
// UnmarshalJSON custom unmarshaller for EncryptedTagKey to handle URL-safe base64 strings.
func (etk *EncryptedTagKey) UnmarshalJSON(data []byte) error {
// Temporary struct to unmarshal into string fields
type Alias struct {
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
KeyVersion int `json:"key_version"`
}
var alias Alias
if err := json.Unmarshal(data, &alias); err != nil {
return fmt.Errorf("failed to unmarshal EncryptedTagKey into alias: %w", err)
}
// Set KeyVersion
etk.KeyVersion = alias.KeyVersion
// Decode Ciphertext - try multiple base64 encodings
if alias.Ciphertext != "" {
ciphertextBytes, err := tryDecodeBase64(alias.Ciphertext)
if err != nil {
return fmt.Errorf("failed to decode EncryptedTagKey.Ciphertext: %w", err)
}
etk.Ciphertext = ciphertextBytes
}
// Decode Nonce - try multiple base64 encodings
if alias.Nonce != "" {
nonceBytes, err := tryDecodeBase64(alias.Nonce)
if err != nil {
return fmt.Errorf("failed to decode EncryptedTagKey.Nonce: %w", err)
}
etk.Nonce = nonceBytes
}
return nil
}
// MasterKeyEncryptedWithRecoveryKey allows account recovery
type MasterKeyEncryptedWithRecoveryKey struct {
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
Nonce []byte `json:"nonce" bson:"nonce"`
}

View file

@ -0,0 +1,39 @@
// monorepo/cloud/maplefile-backend/internal/domain/crypto/domain/keys/rotation.go
package crypto
import (
"time"
"github.com/gocql/gocql"
)
// EncryptedHistoricalKey represents a previous version of a key
type EncryptedHistoricalKey struct {
KeyVersion int `json:"key_version" bson:"key_version"`
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
Nonce []byte `json:"nonce" bson:"nonce"`
RotatedAt time.Time `json:"rotated_at" bson:"rotated_at"`
RotatedReason string `json:"rotated_reason" bson:"rotated_reason"`
// Algorithm used for this key version
Algorithm string `json:"algorithm" bson:"algorithm"`
}
// KeyRotationPolicy defines when and how to rotate keys
type KeyRotationPolicy struct {
MaxKeyAge time.Duration `json:"max_key_age" bson:"max_key_age"`
MaxKeyUsageCount int64 `json:"max_key_usage_count" bson:"max_key_usage_count"`
ForceRotateOnBreach bool `json:"force_rotate_on_breach" bson:"force_rotate_on_breach"`
}
// KeyRotationRecord tracks rotation events
type KeyRotationRecord struct {
ID gocql.UUID `bson:"_id" json:"id"`
EntityType string `bson:"entity_type" json:"entity_type"` // "user", "collection", "file"
EntityID gocql.UUID `bson:"entity_id" json:"entity_id"`
FromVersion int `bson:"from_version" json:"from_version"`
ToVersion int `bson:"to_version" json:"to_version"`
RotatedAt time.Time `bson:"rotated_at" json:"rotated_at"`
RotatedBy gocql.UUID `bson:"rotated_by" json:"rotated_by"`
Reason string `bson:"reason" json:"reason"`
AffectedItems int64 `bson:"affected_items" json:"affected_items"`
}

View file

@ -0,0 +1,54 @@
// cloud/maplefile-backend/internal/maplefile/domain/dashboard/model.go
package dashboard
import (
"time"
)
// Dashboard represents the main dashboard data structure
type Dashboard struct {
Dashboard DashboardData `json:"dashboard"`
}
// DashboardData contains all the dashboard information
type DashboardData struct {
Summary Summary `json:"summary"`
StorageUsageTrend StorageUsageTrend `json:"storageUsageTrend"`
RecentFiles []RecentFile `json:"recentFiles"`
}
// Summary contains the main dashboard statistics
type Summary struct {
TotalFiles int `json:"totalFiles"`
TotalFolders int `json:"totalFolders"`
StorageUsed StorageAmount `json:"storageUsed"`
StorageLimit StorageAmount `json:"storageLimit"`
StorageUsagePercentage int `json:"storageUsagePercentage"`
}
// StorageAmount represents a storage value with its unit
type StorageAmount struct {
Value float64 `json:"value"`
Unit string `json:"unit"`
}
// StorageUsageTrend contains the trend chart data
type StorageUsageTrend struct {
Period string `json:"period"`
DataPoints []DataPoint `json:"dataPoints"`
}
// DataPoint represents a single point in the storage usage trend
type DataPoint struct {
Date string `json:"date"`
Usage StorageAmount `json:"usage"`
}
// RecentFile represents a file in the recent files list
type RecentFile struct {
FileName string `json:"fileName"`
Uploaded string `json:"uploaded"`
UploadedTimestamp time.Time `json:"uploadedTimestamp"`
Type string `json:"type"`
Size StorageAmount `json:"size"`
}

View file

@ -0,0 +1,13 @@
// monorepo/cloud/backend/internal/maplefile/domain/file/constants.go
package file
const (
// FileStatePending is the initial state of a file before it is uploaded.
FileStatePending = "pending"
// FileStateActive indicates that the file is fully uploaded and ready for use.
FileStateActive = "active"
// FileStateDeleted marks the file as deleted, but still accessible for a period but will eventually be permanently removed.
FileStateDeleted = "deleted"
// FileStateArchived indicates that the file is no longer accessible.
FileStateArchived = "archived"
)

View file

@ -0,0 +1,95 @@
// monorepo/cloud/backend/internal/maplefile/domain/file/interface.go
package file
import (
"context"
"time"
"github.com/gocql/gocql"
)
// FileMetadataRepository defines the interface for interacting with file metadata storage.
// It handles operations related to storing, retrieving, updating, and deleting file information (metadata).
type FileMetadataRepository interface {
// Create saves a single File metadata record to the storage.
Create(file *File) error
// CreateMany saves multiple File metadata records to the storage.
CreateMany(files []*File) error
// Get retrieves a single File metadata record (regardless of its state) by its unique identifier (ID) .
Get(id gocql.UUID) (*File, error)
// GetByIDs retrieves multiple File metadata records by their unique identifiers (IDs).
GetByIDs(ids []gocql.UUID) ([]*File, error)
// GetByCollection retrieves all File metadata records associated with a specific collection ID.
GetByCollection(collectionID gocql.UUID) ([]*File, error)
// Update modifies an existing File metadata record in the storage.
Update(file *File) error
// SoftDelete removes a single File metadata record by its unique identifier (ID) by setting its state to deleted.
SoftDelete(id gocql.UUID) error
// HardDelete permanently removes a file metadata record
HardDelete(id gocql.UUID) error
// SoftDeleteMany removes multiple File metadata records by their unique identifiers (IDs) by setting its state to deleted.
SoftDeleteMany(ids []gocql.UUID) error
// HardDeleteMany permanently removes multiple file metadata records
HardDeleteMany(ids []gocql.UUID) error
// CheckIfExistsByID verifies if a File metadata record with the given ID exists in the storage.
CheckIfExistsByID(id gocql.UUID) (bool, error)
// CheckIfUserHasAccess determines if a specific user (userID) has access permissions for a given file (fileID).
CheckIfUserHasAccess(fileID gocql.UUID, userID gocql.UUID) (bool, error)
GetByCreatedByUserID(createdByUserID gocql.UUID) ([]*File, error)
GetByOwnerID(ownerID gocql.UUID) ([]*File, error)
// State management operations
Archive(id gocql.UUID) error
Restore(id gocql.UUID) error
RestoreMany(ids []gocql.UUID) error
// ListSyncData retrieves file sync data with pagination for the specified user and accessible collections
ListSyncData(ctx context.Context, userID gocql.UUID, cursor *FileSyncCursor, limit int64, accessibleCollectionIDs []gocql.UUID) (*FileSyncResponse, error)
// ListRecentFiles retrieves recent files with pagination for the specified user and accessible collections
ListRecentFiles(ctx context.Context, userID gocql.UUID, cursor *RecentFilesCursor, limit int64, accessibleCollectionIDs []gocql.UUID) (*RecentFilesResponse, error)
// CountFilesByUser counts all active files accessible to the user
CountFilesByUser(ctx context.Context, userID gocql.UUID, accessibleCollectionIDs []gocql.UUID) (int, error)
// CountFilesByCollection counts active files in a specific collection
CountFilesByCollection(ctx context.Context, collectionID gocql.UUID) (int, error)
// Storage size calculation methods
GetTotalStorageSizeByOwner(ctx context.Context, ownerID gocql.UUID) (int64, error)
GetTotalStorageSizeByUser(ctx context.Context, userID gocql.UUID, accessibleCollectionIDs []gocql.UUID) (int64, error)
GetTotalStorageSizeByCollection(ctx context.Context, collectionID gocql.UUID) (int64, error)
// IP Anonymization for GDPR compliance
AnonymizeOldIPs(ctx context.Context, cutoffDate time.Time) (int, error)
AnonymizeFileIPsByOwner(ctx context.Context, ownerID gocql.UUID) (int, error) // For GDPR right-to-be-forgotten
// Tag-related operations
// ListByTagID retrieves all files that have the specified tag assigned
// Used for tag update propagation (updating embedded tag data across all files)
ListByTagID(ctx context.Context, tagID gocql.UUID) ([]*File, error)
}
// FileObjectStorageRepository defines the interface for interacting with the actual encrypted file data storage.
// It handles operations related to storing, retrieving, deleting, and generating access URLs for encrypted data.
type FileObjectStorageRepository interface {
// StoreEncryptedData saves encrypted file data to the storage system. It takes the owner's ID,
// the file's ID (metadata ID), and the encrypted byte slice. It returns the storage path
// where the data was saved, or an error.
StoreEncryptedData(ownerID string, fileID string, encryptedData []byte) (string, error)
// GetEncryptedData retrieves encrypted file data from the storage system using its storage path.
// It returns the encrypted data as a byte slice, or an error.
GetEncryptedData(storagePath string) ([]byte, error)
// DeleteEncryptedData removes encrypted file data from the storage system using its storage path.
DeleteEncryptedData(storagePath string) error
// GeneratePresignedDownloadURL creates a temporary, time-limited URL that allows direct download
// of the file data located at the given storage path, with proper content disposition headers.
GeneratePresignedDownloadURL(storagePath string, duration time.Duration) (string, error)
// GeneratePresignedUploadURL creates a temporary, time-limited URL that allows clients to upload
// encrypted file data directly to the storage system at the specified storage path.
GeneratePresignedUploadURL(storagePath string, duration time.Duration) (string, error)
// VerifyObjectExists checks if an object exists at the given storage path.
VerifyObjectExists(storagePath string) (bool, error)
// GetObjectSize returns the size in bytes of the object at the given storage path.
GetObjectSize(storagePath string) (int64, error)
}

View file

@ -0,0 +1,136 @@
// monorepo/cloud/backend/internal/maplefile/domain/file/model.go
package file
import (
"time"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
)
// File represents an encrypted file entity stored in the backend database (MongoDB).
// This entity holds metadata and pointers to the actual file content and thumbnail,
// which are stored separately in S3. All sensitive file metadata and the file itself
// are encrypted client-side before being uploaded. The backend stores only encrypted
// data and necessary non-sensitive identifiers or sizes for management.
type File struct {
// Identifiers
// Unique identifier for this specific file entity.
ID gocql.UUID `bson:"_id" json:"id"`
// Identifier of the collection this file belongs to. Used for grouping and key management.
CollectionID gocql.UUID `bson:"collection_id" json:"collection_id"`
// Identifier of the user who owns this file.
OwnerID gocql.UUID `bson:"owner_id" json:"owner_id"`
// Encryption and Content Details
// Client-side encrypted JSON blob containing file-specific metadata like the original file name,
// MIME type, size of the *unencrypted* data, etc. Encrypted by the client using the file key.
EncryptedMetadata string `bson:"encrypted_metadata" json:"encrypted_metadata"`
// The file-specific data encryption key (DEK) used to encrypt the file content and metadata.
// This key is encrypted by the client using the collection's key (a KEK). The backend
// stores this encrypted key; only a user with access to the KEK can decrypt it.
EncryptedFileKey crypto.EncryptedFileKey `bson:"encrypted_file_key" json:"encrypted_file_key"`
// Version identifier for the encryption scheme or client application version used to
// encrypt this file. Useful for migration or compatibility checks.
EncryptionVersion string `bson:"encryption_version" json:"encryption_version"`
// Cryptographic hash of the *encrypted* file content stored in S3. Used for integrity
// verification upon download *before* decryption.
EncryptedHash string `bson:"encrypted_hash" json:"encrypted_hash"`
// File Storage Object Details
// The unique key or path within the S3 bucket where the main encrypted file content is stored.
// This is an internal backend detail and is not exposed to the client API.
EncryptedFileObjectKey string `bson:"encrypted_file_object_key" json:"-"`
// The size of the *encrypted* file content stored in S3, in bytes. This size is not sensitive
// and is used by the backend for storage accounting, billing, and transfer management.
EncryptedFileSizeInBytes int64 `bson:"encrypted_file_size_in_bytes" json:"encrypted_file_size_in_bytes"`
// Thumbnail Storage Object Details (Optional)
// The unique key or path within the S3 bucket where the encrypted thumbnail image (if generated
// and uploaded) is stored. Internal backend detail, not exposed to the client API.
EncryptedThumbnailObjectKey string `bson:"encrypted_thumbnail_object_key" json:"-"`
// The size of the *encrypted* thumbnail image stored in S3, in bytes. Used for accounting.
// Value will be 0 if no thumbnail exists.
EncryptedThumbnailSizeInBytes int64 `bson:"encrypted_thumbnail_size_in_bytes" json:"encrypted_thumbnail_size_in_bytes"`
// DEPRECATED: Replaced by Tags field below
// TagIDs []gocql.UUID `bson:"tag_ids,omitempty" json:"tag_ids,omitempty"`
// Tags stores full embedded tag data (eliminates frontend API lookups)
// Stored as JSON text in database, marshaled/unmarshaled automatically
Tags []tag.EmbeddedTag `bson:"tags,omitempty" json:"tags,omitempty"`
// Timestamps and conflict resolution
// Timestamp when this file entity was created/uploaded.
CreatedAt time.Time `bson:"created_at" json:"created_at"`
// CreatedByUserID is the ID of the user who created this file.
CreatedByUserID gocql.UUID `bson:"created_by_user_id" json:"created_by_user_id"`
// Timestamp when this file entity's metadata or content was last modified.
ModifiedAt time.Time `bson:"modified_at" json:"modified_at"`
// ModifiedByUserID is the ID of the user whom has last modified this file.
ModifiedByUserID gocql.UUID `bson:"modified_by_user_id" json:"modified_by_user_id"`
// The current version of the file.
Version uint64 `bson:"version" json:"version"` // Every mutation (create, update, delete) is a versioned operation, keep track of the version number with this variable
// State management.
State string `bson:"state" json:"state"` // pending, active, deleted, archived
TombstoneVersion uint64 `bson:"tombstone_version" json:"tombstone_version"` // The `version` number that this collection was deleted at.
TombstoneExpiry time.Time `bson:"tombstone_expiry" json:"tombstone_expiry"`
}
// FileSyncCursor represents cursor-based pagination for sync operations
type FileSyncCursor struct {
LastModified time.Time `json:"last_modified" bson:"last_modified"`
LastID gocql.UUID `json:"last_id" bson:"last_id"`
}
// FileSyncItem represents minimal file data for sync operations
type FileSyncItem struct {
ID gocql.UUID `json:"id" bson:"_id"`
CollectionID gocql.UUID `json:"collection_id" bson:"collection_id"`
Version uint64 `json:"version" bson:"version"`
ModifiedAt time.Time `json:"modified_at" bson:"modified_at"`
State string `json:"state" bson:"state"`
TombstoneVersion uint64 `bson:"tombstone_version" json:"tombstone_version"`
TombstoneExpiry time.Time `bson:"tombstone_expiry" json:"tombstone_expiry"`
EncryptedFileSizeInBytes int64 `bson:"encrypted_file_size_in_bytes" json:"encrypted_file_size_in_bytes"`
}
// FileSyncResponse represents the response for file sync data
type FileSyncResponse struct {
Files []FileSyncItem `json:"files"`
NextCursor *FileSyncCursor `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// RecentFilesCursor represents cursor-based pagination for recent files
type RecentFilesCursor struct {
LastModified time.Time `json:"last_modified" bson:"last_modified"`
LastID gocql.UUID `json:"last_id" bson:"last_id"`
}
// RecentFilesItem represents a file item for recent files listing
type RecentFilesItem struct {
ID gocql.UUID `json:"id" bson:"_id"`
CollectionID gocql.UUID `json:"collection_id" bson:"collection_id"`
OwnerID gocql.UUID `json:"owner_id" bson:"owner_id"`
EncryptedMetadata string `json:"encrypted_metadata" bson:"encrypted_metadata"`
EncryptedFileKey string `json:"encrypted_file_key" bson:"encrypted_file_key"`
EncryptionVersion string `json:"encryption_version" bson:"encryption_version"`
EncryptedHash string `json:"encrypted_hash" bson:"encrypted_hash"`
EncryptedFileSizeInBytes int64 `json:"encrypted_file_size_in_bytes" bson:"encrypted_file_size_in_bytes"`
EncryptedThumbnailSizeInBytes int64 `json:"encrypted_thumbnail_size_in_bytes" bson:"encrypted_thumbnail_size_in_bytes"`
Tags []tag.EmbeddedTag `json:"tags,omitempty" bson:"tags,omitempty"`
CreatedAt time.Time `json:"created_at" bson:"created_at"`
ModifiedAt time.Time `json:"modified_at" bson:"modified_at"`
Version uint64 `json:"version" bson:"version"`
State string `json:"state" bson:"state"`
}
// RecentFilesResponse represents the response for recent files listing
type RecentFilesResponse struct {
Files []RecentFilesItem `json:"files"`
NextCursor *RecentFilesCursor `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}

View file

@ -0,0 +1,45 @@
// monorepo/cloud/backend/internal/maplefile/domain/file/state_validator.go
package file
import "errors"
// StateTransition validates file state transitions
type StateTransition struct {
From string
To string
}
// IsValidStateTransition checks if a file state transition is allowed
func IsValidStateTransition(from, to string) error {
validTransitions := map[StateTransition]bool{
// From pending
{FileStatePending, FileStateActive}: true,
{FileStatePending, FileStateDeleted}: true,
{FileStatePending, FileStateArchived}: false,
// From active
{FileStateActive, FileStatePending}: false,
{FileStateActive, FileStateDeleted}: true,
{FileStateActive, FileStateArchived}: true,
// From deleted (cannot be restored nor archived)
{FileStateDeleted, FileStatePending}: false,
{FileStateDeleted, FileStateActive}: false,
{FileStateDeleted, FileStateArchived}: false,
// From archived (can only be restored to active)
{FileStateArchived, FileStateActive}: true,
// Same state transitions (no-op)
{FileStatePending, FileStatePending}: true,
{FileStateActive, FileStateActive}: true,
{FileStateDeleted, FileStateDeleted}: true,
{FileStateArchived, FileStateArchived}: true,
}
if !validTransitions[StateTransition{from, to}] {
return errors.New("invalid state transition from " + from + " to " + to)
}
return nil
}

View file

@ -0,0 +1,7 @@
// Package inviteemail provides domain types and constants for invitation emails
// sent to non-registered users when someone wants to share a collection with them.
package inviteemail
// DefaultMaxInviteEmailsPerDay is the fallback limit if the environment variable is not set.
// This conservative limit protects email domain reputation.
const DefaultMaxInviteEmailsPerDay = 3

View file

@ -0,0 +1,53 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/domain/storagedailyusage/interface.go
package storagedailyusage
import (
"context"
"time"
"github.com/gocql/gocql"
)
// StorageDailyUsageRepository defines the interface for daily storage usage aggregates
type StorageDailyUsageRepository interface {
Create(ctx context.Context, usage *StorageDailyUsage) error
CreateMany(ctx context.Context, usages []*StorageDailyUsage) error
GetByUserAndDay(ctx context.Context, userID gocql.UUID, usageDay time.Time) (*StorageDailyUsage, error)
GetByUserDateRange(ctx context.Context, userID gocql.UUID, startDay, endDay time.Time) ([]*StorageDailyUsage, error)
UpdateOrCreate(ctx context.Context, usage *StorageDailyUsage) error
IncrementUsage(ctx context.Context, userID gocql.UUID, usageDay time.Time, totalBytes, addBytes, removeBytes int64) error
DeleteByUserAndDay(ctx context.Context, userID gocql.UUID, usageDay time.Time) error
DeleteByUserID(ctx context.Context, userID gocql.UUID) error
GetLast7DaysTrend(ctx context.Context, userID gocql.UUID) (*StorageUsageTrend, error)
GetMonthlyTrend(ctx context.Context, userID gocql.UUID, year int, month time.Month) (*StorageUsageTrend, error)
GetYearlyTrend(ctx context.Context, userID gocql.UUID, year int) (*StorageUsageTrend, error)
GetCurrentMonthUsage(ctx context.Context, userID gocql.UUID) (*StorageUsageSummary, error)
GetCurrentYearUsage(ctx context.Context, userID gocql.UUID) (*StorageUsageSummary, error)
}
// StorageUsageTrend represents usage trend over a period
type StorageUsageTrend struct {
UserID gocql.UUID `json:"user_id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
DailyUsages []*StorageDailyUsage `json:"daily_usages"`
TotalAdded int64 `json:"total_added"`
TotalRemoved int64 `json:"total_removed"`
NetChange int64 `json:"net_change"`
AverageDailyAdd int64 `json:"average_daily_add"`
PeakUsageDay *time.Time `json:"peak_usage_day,omitempty"`
PeakUsageBytes int64 `json:"peak_usage_bytes"`
}
// StorageUsageSummary represents a summary of storage usage
type StorageUsageSummary struct {
UserID gocql.UUID `json:"user_id"`
Period string `json:"period"` // "month" or "year"
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
CurrentUsage int64 `json:"current_usage_bytes"`
TotalAdded int64 `json:"total_added_bytes"`
TotalRemoved int64 `json:"total_removed_bytes"`
NetChange int64 `json:"net_change_bytes"`
DaysWithData int `json:"days_with_data"`
}

View file

@ -0,0 +1,26 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/domain/storagedailyusage/model.go
package storagedailyusage
import (
"time"
"github.com/gocql/gocql"
)
type StorageDailyUsage struct {
UserID gocql.UUID `json:"user_id"` // Partition key
UsageDay time.Time `json:"usage_day"` // Clustering key (date only)
TotalBytes int64 `json:"total_bytes"`
TotalAddBytes int64 `json:"total_add_bytes"`
TotalRemoveBytes int64 `json:"total_remove_bytes"`
}
//
// Use gocql.UUID from the github.com/gocql/gocql driver.
//
// For consistency, always store and retrieve DATE fields (like event_day and usage_day) as time.Time, but truncate to date only before inserting:
//
// ```go
// usageDay := time.Now().Truncate(24 * time.Hour)
// ```
//

View file

@ -0,0 +1,23 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/domain/storageusageevent/interface.go
package storageusageevent
import (
"context"
"time"
"github.com/gocql/gocql"
)
// StorageUsageEventRepository defines the interface for storage usage events
type StorageUsageEventRepository interface {
Create(ctx context.Context, event *StorageUsageEvent) error
CreateMany(ctx context.Context, events []*StorageUsageEvent) error
GetByUserAndDay(ctx context.Context, userID gocql.UUID, eventDay time.Time) ([]*StorageUsageEvent, error)
GetByUserDateRange(ctx context.Context, userID gocql.UUID, startDay, endDay time.Time) ([]*StorageUsageEvent, error)
DeleteByUserAndDay(ctx context.Context, userID gocql.UUID, eventDay time.Time) error
DeleteByUserID(ctx context.Context, userID gocql.UUID) error
GetLast7DaysEvents(ctx context.Context, userID gocql.UUID) ([]*StorageUsageEvent, error)
GetLastNDaysEvents(ctx context.Context, userID gocql.UUID, days int) ([]*StorageUsageEvent, error)
GetMonthlyEvents(ctx context.Context, userID gocql.UUID, year int, month time.Month) ([]*StorageUsageEvent, error)
GetYearlyEvents(ctx context.Context, userID gocql.UUID, year int) ([]*StorageUsageEvent, error)
}

View file

@ -0,0 +1,16 @@
// monorepo/cloud/maplefile-backend/internal/maplefile/domain/storageusageevent/model.go
package storageusageevent
import (
"time"
"github.com/gocql/gocql"
)
type StorageUsageEvent struct {
UserID gocql.UUID `json:"user_id"` // Partition key
EventDay time.Time `json:"event_day"` // Partition key (date only)
EventTime time.Time `json:"event_time"` // Clustering key
FileSize int64 `json:"file_size"` // Bytes
Operation string `json:"operation"` // "add" or "remove"
}

View file

@ -0,0 +1,23 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag/constants.go
package tag
const (
// Tag States
TagStateActive = "active"
TagStateDeleted = "deleted"
TagStateArchived = "archived"
// Entity Types
EntityTypeCollection = "collection"
EntityTypeFile = "file"
// Default Tag Names
DefaultTagImportant = "Important"
DefaultTagWork = "Work"
DefaultTagPersonal = "Personal"
// Default Tag Colors (hex format)
DefaultColorImportant = "#EF4444" // Red
DefaultColorWork = "#3B82F6" // Blue
DefaultColorPersonal = "#10B981" // Green
)

View file

@ -0,0 +1,26 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag/interface.go
package tag
import (
"context"
"github.com/gocql/gocql"
)
// Repository defines the interface for tag data access operations
type Repository interface {
// Tag CRUD operations
Create(ctx context.Context, tag *Tag) error
GetByID(ctx context.Context, id gocql.UUID) (*Tag, error)
ListByUser(ctx context.Context, userID gocql.UUID) ([]*Tag, error)
Update(ctx context.Context, tag *Tag) error
DeleteByID(ctx context.Context, userID, id gocql.UUID) error
// Tag Assignment operations
AssignTag(ctx context.Context, assignment *TagAssignment) error
UnassignTag(ctx context.Context, tagID, entityID gocql.UUID, entityType string) error
GetTagsForEntity(ctx context.Context, entityID gocql.UUID, entityType string) ([]*Tag, error)
GetEntitiesWithTag(ctx context.Context, tagID gocql.UUID, entityType string) ([]gocql.UUID, error)
GetAssignmentsByTag(ctx context.Context, tagID gocql.UUID) ([]*TagAssignment, error)
GetAssignmentsByEntity(ctx context.Context, entityID gocql.UUID, entityType string) ([]*TagAssignment, error)
}

View file

@ -0,0 +1,89 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag/model.go
package tag
import (
"time"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto"
)
// Tag represents a user-defined label with color that can be assigned to collections or files
// All sensitive data (name, color) is encrypted end-to-end using the tag's unique encryption key
type Tag struct {
// Identifiers
ID gocql.UUID `bson:"_id" json:"id"`
UserID gocql.UUID `bson:"user_id" json:"user_id"` // Owner of the tag
// Encrypted Tag Details
// EncryptedName is the tag label (e.g., "Important", "Work") encrypted with the tag key
EncryptedName string `bson:"encrypted_name" json:"encrypted_name"`
// EncryptedColor is the hex color code (e.g., "#FF5733") encrypted with the tag key
EncryptedColor string `bson:"encrypted_color" json:"encrypted_color"`
// EncryptedTagKey is the unique symmetric key used to encrypt this tag's data (name and color)
// This key is encrypted with the user's master key for storage and transmission
EncryptedTagKey *crypto.EncryptedTagKey `bson:"encrypted_tag_key" json:"encrypted_tag_key"`
// Timestamps and versioning
CreatedAt time.Time `bson:"created_at" json:"created_at"`
ModifiedAt time.Time `bson:"modified_at" json:"modified_at"`
Version uint64 `bson:"version" json:"version"` // Versioning for sync
// State management
State string `bson:"state" json:"state"` // active, deleted, archived
}
// TagAssignment represents the assignment of a tag to a collection or file
type TagAssignment struct {
// Identifiers
ID gocql.UUID `bson:"_id" json:"id"`
UserID gocql.UUID `bson:"user_id" json:"user_id"` // User who assigned the tag
TagID gocql.UUID `bson:"tag_id" json:"tag_id"` // Reference to the tag
EntityID gocql.UUID `bson:"entity_id" json:"entity_id"` // Collection or File ID
// EntityType indicates whether this is a "collection" or "file"
EntityType string `bson:"entity_type" json:"entity_type"`
// Timestamps
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// TagListFilter represents filter criteria for listing tags
type TagListFilter struct {
UserID gocql.UUID
State string // Optional: filter by state
}
// TagAssignmentFilter represents filter criteria for tag assignments
type TagAssignmentFilter struct {
TagID *gocql.UUID
EntityID *gocql.UUID
EntityType *string
UserID *gocql.UUID
}
// EmbeddedTag represents tag data that is embedded in collections and files
// This eliminates the need for frontend API lookups to get tag colors
type EmbeddedTag struct {
// Core identifiers and data
ID gocql.UUID `bson:"id" json:"id"`
EncryptedName string `bson:"encrypted_name" json:"encrypted_name"`
EncryptedColor string `bson:"encrypted_color" json:"encrypted_color"`
EncryptedTagKey *crypto.EncryptedTagKey `bson:"encrypted_tag_key" json:"encrypted_tag_key"`
// For cache invalidation - detect stale embedded data
ModifiedAt time.Time `bson:"modified_at" json:"modified_at"`
}
// ToEmbeddedTag converts a Tag to an EmbeddedTag for embedding in collections/files
func (t *Tag) ToEmbeddedTag() *EmbeddedTag {
if t == nil {
return nil
}
return &EmbeddedTag{
ID: t.ID,
EncryptedName: t.EncryptedName,
EncryptedColor: t.EncryptedColor,
EncryptedTagKey: t.EncryptedTagKey,
ModifiedAt: t.ModifiedAt,
}
}

View file

@ -0,0 +1,23 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user/interface.go
package user
import (
"context"
"time"
"github.com/gocql/gocql"
)
// Repository Interface for user management.
type Repository interface {
Create(ctx context.Context, m *User) error
GetByID(ctx context.Context, id gocql.UUID) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
GetByVerificationCode(ctx context.Context, verificationCode string) (*User, error)
DeleteByID(ctx context.Context, id gocql.UUID) error
DeleteByEmail(ctx context.Context, email string) error
CheckIfExistsByEmail(ctx context.Context, email string) (bool, error)
UpdateByID(ctx context.Context, m *User) error
AnonymizeOldIPs(ctx context.Context, cutoffDate time.Time) (int, error)
AnonymizeUserIPs(ctx context.Context, userID gocql.UUID) error // For GDPR right-to-be-forgotten
}

View file

@ -0,0 +1,153 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user/model.go
package user
import (
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto"
"github.com/gocql/gocql"
)
const (
UserStatusActive = 1 // User is active and can log in.
UserStatusLocked = 50 // User account is locked, typically due to too many failed login attempts.
UserStatusArchived = 100 // User account is archived and cannot log in.
UserRoleRoot = 1 // Root user, has all permissions
UserRoleCompany = 2 // Company user, has permissions for company-related operations
UserRoleIndividual = 3 // Individual user, has permissions for individual-related operations
UserProfileVerificationStatusUnverified = 1 // The user's profile has not yet been submitted for verification.
UserProfileVerificationStatusSubmittedForReview = 2 // The user's profile has been submitted and is awaiting review.
UserProfileVerificationStatusApproved = 3 // The user's profile has been approved.
UserProfileVerificationStatusRejected = 4 // The user's profile has been rejected.
// StorePendingStatus indicates this store needs to be reviewed by CPS and approved / rejected.
StorePendingStatus = 1 // Store is pending review.
StoreActiveStatus = 2 // Store is active and can be used.
StoreRejectedStatus = 3 // Store has been rejected.
StoreErrorStatus = 4 // Store has encountered an error.
StoreArchivedStatus = 5 // Store has been archived.
EstimatedSubmissionsPerMonth1To10 = 1 // Estimated submissions per month: 1 to 10
EstimatedSubmissionsPerMonth10To25 = 2 // Estimated submissions per month: 10 to 25
EstimatedSubmissionsPerMonth25To50 = 3 // Estimated submissions per month: 25 to 50
EstimatedSubmissionsPerMonth50To10 = 4 // Estimated submissions per month: 50 to 100
EstimatedSubmissionsPerMonth100Plus = 5 // Estimated submissions per month: 100+
HasOtherGradingServiceYes = 1 // Has other grading service: Yes
HasOtherGradingServiceNo = 2 // Has other grading service: No
RequestWelcomePackageYes = 1 // Request welcome package: Yes
RequestWelcomePackageNo = 2 // Request welcome package: No
SpecialCollection040001 = 1
UserCodeTypeEmailVerification = "email_verification"
UserCodeTypePasswordReset = "password_reset"
)
type UserProfileData struct {
Phone string `bson:"phone" json:"phone,omitempty"`
Country string `bson:"country" json:"country,omitempty"`
Region string `bson:"region" json:"region,omitempty"`
City string `bson:"city" json:"city,omitempty"`
PostalCode string `bson:"postal_code" json:"postal_code,omitempty"`
AddressLine1 string `bson:"address_line1" json:"address_line1,omitempty"`
AddressLine2 string `bson:"address_line2" json:"address_line2,omitempty"`
HasShippingAddress bool `bson:"has_shipping_address" json:"has_shipping_address,omitempty"`
ShippingName string `bson:"shipping_name" json:"shipping_name,omitempty"`
ShippingPhone string `bson:"shipping_phone" json:"shipping_phone,omitempty"`
ShippingCountry string `bson:"shipping_country" json:"shipping_country,omitempty"`
ShippingRegion string `bson:"shipping_region" json:"shipping_region,omitempty"`
ShippingCity string `bson:"shipping_city" json:"shipping_city,omitempty"`
ShippingPostalCode string `bson:"shipping_postal_code" json:"shipping_postal_code,omitempty"`
ShippingAddressLine1 string `bson:"shipping_address_line1" json:"shipping_address_line1,omitempty"`
ShippingAddressLine2 string `bson:"shipping_address_line2" json:"shipping_address_line2,omitempty"`
Timezone string `bson:"timezone" json:"timezone"`
AgreeTermsOfService bool `bson:"agree_terms_of_service" json:"agree_terms_of_service,omitempty"`
AgreePromotions bool `bson:"agree_promotions" json:"agree_promotions,omitempty"`
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `bson:"agree_to_tracking_across_third_party_apps_and_services" json:"agree_to_tracking_across_third_party_apps_and_services,omitempty"`
// Email share notification preferences
ShareNotificationsEnabled *bool `bson:"share_notifications_enabled" json:"share_notifications_enabled,omitempty"`
}
type UserSecurityData struct {
WasEmailVerified bool `bson:"was_email_verified" json:"was_email_verified,omitempty"`
Code string `bson:"code,omitempty" json:"code,omitempty"`
CodeType string `bson:"code_type,omitempty" json:"code_type,omitempty"` // -- 'email_verification' or 'password_reset'
CodeExpiry time.Time `bson:"code_expiry,omitempty" json:"code_expiry"`
// --- E2EE Related ---
PasswordSalt []byte `json:"password_salt" bson:"password_salt"`
// KDFParams stores the key derivation function parameters used to derive the user's password hash.
KDFParams crypto.KDFParams `json:"kdf_params" bson:"kdf_params"`
EncryptedMasterKey crypto.EncryptedMasterKey `json:"encrypted_master_key" bson:"encrypted_master_key"`
PublicKey crypto.PublicKey `json:"public_key" bson:"public_key"`
EncryptedPrivateKey crypto.EncryptedPrivateKey `json:"encrypted_private_key" bson:"encrypted_private_key"`
EncryptedRecoveryKey crypto.EncryptedRecoveryKey `json:"encrypted_recovery_key" bson:"encrypted_recovery_key"`
MasterKeyEncryptedWithRecoveryKey crypto.MasterKeyEncryptedWithRecoveryKey `json:"master_key_encrypted_with_recovery_key" bson:"master_key_encrypted_with_recovery_key"`
EncryptedChallenge []byte `json:"encrypted_challenge,omitempty" bson:"encrypted_challenge,omitempty"`
VerificationID string `json:"verification_id" bson:"verification_id"`
// Track KDF upgrade status
LastPasswordChange time.Time `json:"last_password_change" bson:"last_password_change"`
KDFParamsNeedUpgrade bool `json:"kdf_params_need_upgrade" bson:"kdf_params_need_upgrade"`
// Key rotation tracking fields
CurrentKeyVersion int `json:"current_key_version" bson:"current_key_version"`
LastKeyRotation *time.Time `json:"last_key_rotation,omitempty" bson:"last_key_rotation,omitempty"`
KeyRotationPolicy *crypto.KeyRotationPolicy `json:"key_rotation_policy,omitempty" bson:"key_rotation_policy,omitempty"`
// OTPEnabled controls whether we force 2FA or not during login.
OTPEnabled bool `bson:"otp_enabled" json:"otp_enabled"`
// OTPVerified indicates user has successfully validated their opt token afer enabling 2FA thus turning it on.
OTPVerified bool `bson:"otp_verified" json:"otp_verified"`
// OTPValidated automatically gets set as `false` on successful login and then sets `true` once successfully validated by 2FA.
OTPValidated bool `bson:"otp_validated" json:"otp_validated"`
// OTPSecret the unique one-time password secret to be shared between our
// backend and 2FA authenticator sort of apps that support `TOPT`.
OTPSecret string `bson:"otp_secret" json:"-"`
// OTPAuthURL is the URL used to share.
OTPAuthURL string `bson:"otp_auth_url" json:"-"`
// OTPBackupCodeHash is the one-time use backup code which resets the 2FA settings and allow the user to setup 2FA from scratch for the user.
OTPBackupCodeHash string `bson:"otp_backup_code_hash" json:"-"`
// OTPBackupCodeHashAlgorithm tracks the hashing algorithm used.
OTPBackupCodeHashAlgorithm string `bson:"otp_backup_code_hash_algorithm" json:"-"`
}
type UserMetadata struct {
CreatedFromIPAddress string `bson:"created_from_ip_address" json:"created_from_ip_address"`
CreatedByUserID gocql.UUID `bson:"created_by_user_id" json:"created_by_user_id"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
CreatedByName string `bson:"created_by_name" json:"created_by_name"`
ModifiedFromIPAddress string `bson:"modified_from_ip_address" json:"modified_from_ip_address"`
ModifiedByUserID gocql.UUID `bson:"modified_by_user_id" json:"modified_by_user_id"`
ModifiedAt time.Time `bson:"modified_at" json:"modified_at"`
ModifiedByName string `bson:"modified_by_name" json:"modified_by_name"`
LastLoginAt time.Time `json:"last_login_at" bson:"last_login_at"`
}
type User struct {
ID gocql.UUID `bson:"_id" json:"id"`
Email string `bson:"email" json:"email"`
FirstName string `bson:"first_name" json:"first_name"`
LastName string `bson:"last_name" json:"last_name"`
Name string `bson:"name" json:"name"`
LexicalName string `bson:"lexical_name" json:"lexical_name"`
Role int8 `bson:"role" json:"role"`
Status int8 `bson:"status" json:"status"`
Timezone string `bson:"timezone" json:"timezone"`
ProfileData *UserProfileData `bson:"profile_data" json:"profile_data"`
SecurityData *UserSecurityData `bson:"security_data" json:"security_data"`
Metadata *UserMetadata `bson:"metadata" json:"metadata"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
ModifiedAt time.Time `bson:"modified_at" json:"modified_at"`
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more