Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
43
cloud/maplefile-backend/internal/domain/collection/filter.go
Normal file
43
cloud/maplefile-backend/internal/domain/collection/filter.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
124
cloud/maplefile-backend/internal/domain/collection/model.go
Normal file
124
cloud/maplefile-backend/internal/domain/collection/model.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
69
cloud/maplefile-backend/internal/domain/crypto/kdf.go
Normal file
69
cloud/maplefile-backend/internal/domain/crypto/kdf.go
Normal 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"`
|
||||
}
|
||||
355
cloud/maplefile-backend/internal/domain/crypto/model.go
Normal file
355
cloud/maplefile-backend/internal/domain/crypto/model.go
Normal 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"`
|
||||
}
|
||||
39
cloud/maplefile-backend/internal/domain/crypto/rotation.go
Normal file
39
cloud/maplefile-backend/internal/domain/crypto/rotation.go
Normal 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"`
|
||||
}
|
||||
54
cloud/maplefile-backend/internal/domain/dashboard/model.go
Normal file
54
cloud/maplefile-backend/internal/domain/dashboard/model.go
Normal 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"`
|
||||
}
|
||||
13
cloud/maplefile-backend/internal/domain/file/constants.go
Normal file
13
cloud/maplefile-backend/internal/domain/file/constants.go
Normal 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"
|
||||
)
|
||||
95
cloud/maplefile-backend/internal/domain/file/interface.go
Normal file
95
cloud/maplefile-backend/internal/domain/file/interface.go
Normal 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)
|
||||
}
|
||||
136
cloud/maplefile-backend/internal/domain/file/model.go
Normal file
136
cloud/maplefile-backend/internal/domain/file/model.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
// ```
|
||||
//
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
23
cloud/maplefile-backend/internal/domain/tag/constants.go
Normal file
23
cloud/maplefile-backend/internal/domain/tag/constants.go
Normal 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
|
||||
)
|
||||
26
cloud/maplefile-backend/internal/domain/tag/interface.go
Normal file
26
cloud/maplefile-backend/internal/domain/tag/interface.go
Normal 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)
|
||||
}
|
||||
89
cloud/maplefile-backend/internal/domain/tag/model.go
Normal file
89
cloud/maplefile-backend/internal/domain/tag/model.go
Normal 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,
|
||||
}
|
||||
}
|
||||
23
cloud/maplefile-backend/internal/domain/user/interface.go
Normal file
23
cloud/maplefile-backend/internal/domain/user/interface.go
Normal 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
|
||||
}
|
||||
153
cloud/maplefile-backend/internal/domain/user/model.go
Normal file
153
cloud/maplefile-backend/internal/domain/user/model.go
Normal 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"`
|
||||
}
|
||||
122
cloud/maplefile-backend/internal/interface/http/README.md
Normal file
122
cloud/maplefile-backend/internal/interface/http/README.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
91
cloud/maplefile-backend/internal/interface/http/file/get.go
Normal file
91
cloud/maplefile-backend/internal/interface/http/file/get.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
136
cloud/maplefile-backend/internal/interface/http/file/provider.go
Normal file
136
cloud/maplefile-backend/internal/interface/http/file/provider.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
135
cloud/maplefile-backend/internal/interface/http/file/update.go
Normal file
135
cloud/maplefile-backend/internal/interface/http/file/update.go
Normal 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
|
||||
}
|
||||
}
|
||||
258
cloud/maplefile-backend/internal/interface/http/handlers.go
Normal file
258
cloud/maplefile-backend/internal/interface/http/handlers.go
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
96
cloud/maplefile-backend/internal/interface/http/me/delete.go
Normal file
96
cloud/maplefile-backend/internal/interface/http/me/delete.go
Normal 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)
|
||||
}
|
||||
75
cloud/maplefile-backend/internal/interface/http/me/get.go
Normal file
75
cloud/maplefile-backend/internal/interface/http/me/get.go
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
110
cloud/maplefile-backend/internal/interface/http/me/update.go
Normal file
110
cloud/maplefile-backend/internal/interface/http/me/update.go
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
221
cloud/maplefile-backend/internal/interface/http/provider.go
Normal file
221
cloud/maplefile-backend/internal/interface/http/provider.go
Normal 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,
|
||||
)
|
||||
}
|
||||
119
cloud/maplefile-backend/internal/interface/http/routes.go
Normal file
119
cloud/maplefile-backend/internal/interface/http/routes.go
Normal 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)
|
||||
}
|
||||
}
|
||||
347
cloud/maplefile-backend/internal/interface/http/server.go
Normal file
347
cloud/maplefile-backend/internal/interface/http/server.go
Normal 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)
|
||||
}
|
||||
134
cloud/maplefile-backend/internal/interface/http/tag/assign.go
Normal file
134
cloud/maplefile-backend/internal/interface/http/tag/assign.go
Normal 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)
|
||||
}
|
||||
202
cloud/maplefile-backend/internal/interface/http/tag/create.go
Normal file
202
cloud/maplefile-backend/internal/interface/http/tag/create.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
76
cloud/maplefile-backend/internal/interface/http/tag/get.go
Normal file
76
cloud/maplefile-backend/internal/interface/http/tag/get.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
73
cloud/maplefile-backend/internal/interface/http/tag/list.go
Normal file
73
cloud/maplefile-backend/internal/interface/http/tag/list.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue