first commit

This commit is contained in:
domrichardson
2026-03-24 16:03:04 +00:00
commit df40cc57e1
80 changed files with 16766 additions and 0 deletions

View File

@@ -0,0 +1,440 @@
package dto
import (
"github.com/noteapp/backend/internal/domain/entities"
)
// ========== AUTH DTOs ==========
// RegisterRequest represents a registration request
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,min=3,max=20"`
Password string `json:"password" validate:"required,min=8"`
PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password"`
FirstName string `json:"first_name" validate:"max=50"`
LastName string `json:"last_name" validate:"max=50"`
}
// LoginRequest represents a login request
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
// LoginResponse represents a login response
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
User *UserDTO `json:"user"`
ExpiresIn int `json:"expires_in"`
}
// AuthProviderDTO represents an OAuth/OIDC provider in API responses.
type AuthProviderDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
AuthorizationURL string `json:"authorization_url,omitempty"`
TokenURL string `json:"token_url,omitempty"`
UserInfoURL string `json:"userinfo_url,omitempty"`
Scopes []string `json:"scopes"`
IDTokenClaim string `json:"id_token_claim,omitempty"`
IsActive bool `json:"is_active"`
}
// CreateAuthProviderRequest represents an OAuth/OIDC provider creation request.
type CreateAuthProviderRequest struct {
Name string `json:"name"`
Type string `json:"type"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
AuthorizationURL string `json:"authorization_url"`
TokenURL string `json:"token_url"`
UserInfoURL string `json:"userinfo_url"`
Scopes []string `json:"scopes"`
IDTokenClaim string `json:"id_token_claim,omitempty"`
IsActive bool `json:"is_active"`
}
// FeatureFlagsDTO represents app-wide feature flags in API responses.
type FeatureFlagsDTO struct {
RegistrationEnabled bool `json:"registration_enabled"`
ProviderLoginEnabled bool `json:"provider_login_enabled"`
PublicSharingEnabled bool `json:"public_sharing_enabled"`
}
// UpdateFeatureFlagsRequest represents admin payload for feature flag updates.
type UpdateFeatureFlagsRequest struct {
RegistrationEnabled bool `json:"registration_enabled"`
ProviderLoginEnabled bool `json:"provider_login_enabled"`
PublicSharingEnabled bool `json:"public_sharing_enabled"`
}
// UserDTO represents a user in API responses
type UserDTO struct {
ID string `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Avatar string `json:"avatar,omitempty"`
GroupIDs []string `json:"group_ids,omitempty"`
Permissions []string `json:"permissions,omitempty"`
EmailVerified bool `json:"email_verified"`
}
// NewUserDTO creates a DTO from a user entity
func NewUserDTO(user *entities.User) *UserDTO {
groupIDs := make([]string, 0, len(user.GroupIDs))
for _, groupID := range user.GroupIDs {
groupIDs = append(groupIDs, groupID.Hex())
}
return &UserDTO{
ID: user.ID.Hex(),
Email: user.Email,
Username: user.Username,
FirstName: user.FirstName,
LastName: user.LastName,
Avatar: user.Avatar,
GroupIDs: groupIDs,
Permissions: user.Permissions,
EmailVerified: user.EmailVerified,
}
}
// AdminUserDTO extends UserDTO with admin-visible fields
type AdminUserDTO struct {
*UserDTO
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
}
// PermissionGroupDTO represents a permission group in API responses.
type PermissionGroupDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
IsSystem bool `json:"is_system"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CreatePermissionGroupRequest represents group creation input.
type CreatePermissionGroupRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
// UpdatePermissionGroupRequest represents group update input.
type UpdatePermissionGroupRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
// UpdateUserGroupsRequest represents user group assignment input.
type UpdateUserGroupsRequest struct {
GroupIDs []string `json:"group_ids"`
}
// NewAdminUserDTO creates an admin DTO from a user entity
func NewAdminUserDTO(user *entities.User) *AdminUserDTO {
return &AdminUserDTO{
UserDTO: NewUserDTO(user),
IsActive: user.IsActive,
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// NewPermissionGroupDTO creates a DTO from a permission group entity.
func NewPermissionGroupDTO(group *entities.PermissionGroup) *PermissionGroupDTO {
return &PermissionGroupDTO{
ID: group.ID.Hex(),
Name: group.Name,
Description: group.Description,
Permissions: group.Permissions,
IsSystem: group.IsSystem,
CreatedAt: group.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: group.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// AddSpaceMemberRequest represents a request to add a member to a space
type AddSpaceMemberRequest struct {
UserID string `json:"user_id"`
}
// SpaceMemberDTO represents a member in a space
type SpaceMemberDTO struct {
UserID string `json:"user_id"`
Username string `json:"username"`
JoinedAt string `json:"joined_at"`
}
// UserOptionDTO is a lightweight user object for dropdowns
type UserOptionDTO struct {
ID string `json:"id"`
Username string `json:"username"`
}
// NewAuthProviderDTO creates a DTO from an auth provider entity.
func NewAuthProviderDTO(provider *entities.AuthProvider) *AuthProviderDTO {
return &AuthProviderDTO{
ID: provider.ID.Hex(),
Name: provider.Name,
Type: provider.Type,
AuthorizationURL: provider.AuthorizationURL,
TokenURL: provider.TokenURL,
UserInfoURL: provider.UserInfoURL,
Scopes: provider.Scopes,
IDTokenClaim: provider.IDTokenClaim,
IsActive: provider.IsActive,
}
}
// NewFeatureFlagsDTO creates a DTO from feature flags entity.
func NewFeatureFlagsDTO(flags *entities.FeatureFlags) *FeatureFlagsDTO {
if flags == nil {
flags = entities.NewDefaultFeatureFlags()
}
return &FeatureFlagsDTO{
RegistrationEnabled: flags.RegistrationEnabled,
ProviderLoginEnabled: flags.ProviderLoginEnabled,
PublicSharingEnabled: flags.PublicSharingEnabled,
}
}
// ========== SPACE DTOs ==========
// CreateSpaceRequest represents a space creation request
type CreateSpaceRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Description string `json:"description" validate:"max=500"`
Icon string `json:"icon,omitempty" validate:"max=20"`
IsPublic bool `json:"is_public"`
}
// SpaceDTO represents a space in API responses
type SpaceDTO struct {
ID string `json:"id"`
Name string `json:"name"`
PermissionKey string `json:"permission_key"`
Description string `json:"description"`
Icon string `json:"icon,omitempty"`
OwnerID string `json:"owner_id"`
IsPublic bool `json:"is_public"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NewSpaceDTO creates a DTO from a space entity
func NewSpaceDTO(space *entities.Space) *SpaceDTO {
dto := &SpaceDTO{
ID: space.ID.Hex(),
Name: space.Name,
PermissionKey: entities.SpacePermissionToken(space.Name),
Description: space.Description,
Icon: space.Icon,
OwnerID: space.OwnerID.Hex(),
IsPublic: space.IsPublic,
CreatedAt: space.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: space.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
return dto
}
// ========== NOTE DTOs ==========
// CreateNoteRequest represents a note creation request
type CreateNoteRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Description string `json:"description" validate:"max=500"`
Content string `json:"content"`
NotePassword string `json:"note_password,omitempty" validate:"omitempty,min=4,max=128"`
Tags []string `json:"tags"`
CategoryID *string `json:"category_id,omitempty"`
IsPinned bool `json:"is_pinned"`
IsFavorite bool `json:"is_favorite"`
IsPublic bool `json:"is_public"`
}
// UpdateNoteRequest represents a note update request
type UpdateNoteRequest struct {
Title string `json:"title" validate:"min=1,max=255"`
Description *string `json:"description,omitempty" validate:"omitempty,max=500"`
Content string `json:"content"`
NotePassword *string `json:"note_password,omitempty" validate:"omitempty,max=128"`
Tags []string `json:"tags"`
CategoryID *string `json:"category_id,omitempty"`
IsPinned *bool `json:"is_pinned"`
IsFavorite *bool `json:"is_favorite"`
IsPublic *bool `json:"is_public,omitempty"`
}
// UnlockNoteRequest represents a password unlock request for protected notes
type UnlockNoteRequest struct {
Password string `json:"password" validate:"required,min=1,max=128"`
}
// NoteDTO represents a note in API responses
type NoteDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
CategoryID *string `json:"category_id,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
Tags []string `json:"tags"`
IsPinned bool `json:"is_pinned"`
IsFavorite bool `json:"is_favorite"`
IsPublic bool `json:"is_public"`
IsPasswordProtected bool `json:"is_password_protected"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NoteListItemDTO represents a lightweight note payload for list/tree endpoints
type NoteListItemDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
CategoryID *string `json:"category_id,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
IsPinned bool `json:"is_pinned"`
IsFavorite bool `json:"is_favorite"`
IsPublic bool `json:"is_public"`
IsPasswordProtected bool `json:"is_password_protected"`
UpdatedAt string `json:"updated_at"`
}
// NewNoteDTO creates a DTO from a note entity
func NewNoteDTO(note *entities.Note) *NoteDTO {
var categoryID *string
if note.CategoryID != nil {
id := note.CategoryID.Hex()
categoryID = &id
}
return &NoteDTO{
ID: note.ID.Hex(),
SpaceID: note.SpaceID.Hex(),
CategoryID: categoryID,
Title: note.Title,
Description: note.Description,
Content: note.Content,
Tags: note.Tags,
IsPinned: note.IsPinned,
IsFavorite: note.IsFavorite,
IsPublic: note.IsPublic,
IsPasswordProtected: note.IsPasswordProtected,
CreatedBy: note.CreatedBy.Hex(),
UpdatedBy: note.UpdatedBy.Hex(),
CreatedAt: note.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: note.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// NewNoteListItemDTO creates a lightweight DTO from a note entity
func NewNoteListItemDTO(note *entities.Note) *NoteListItemDTO {
var categoryID *string
if note.CategoryID != nil {
id := note.CategoryID.Hex()
categoryID = &id
}
return &NoteListItemDTO{
ID: note.ID.Hex(),
SpaceID: note.SpaceID.Hex(),
CategoryID: categoryID,
Title: note.Title,
Description: note.Description,
IsPinned: note.IsPinned,
IsFavorite: note.IsFavorite,
IsPublic: note.IsPublic,
IsPasswordProtected: note.IsPasswordProtected,
UpdatedAt: note.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ========== CATEGORY DTOs ==========
// CreateCategoryRequest represents a category creation request
type CreateCategoryRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Description string `json:"description" validate:"max=500"`
ParentID *string `json:"parent_id,omitempty"`
Icon string `json:"icon,omitempty" validate:"max=20"`
}
// UpdateCategoryRequest represents a category update request
type UpdateCategoryRequest struct {
Name string `json:"name" validate:"min=1,max=100"`
Description string `json:"description" validate:"max=500"`
Icon string `json:"icon,omitempty" validate:"max=20"`
}
// CategoryDTO represents a category in API responses
type CategoryDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *string `json:"parent_id,omitempty"`
Icon string `json:"icon,omitempty"`
Order int `json:"order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CategoryTreeDTO represents a category with its subcategories and notes
type CategoryTreeDTO struct {
*CategoryDTO
Subcategories []*CategoryTreeDTO `json:"subcategories"`
Notes []*NoteListItemDTO `json:"notes"`
}
// NewCategoryDTO creates a DTO from a category entity
func NewCategoryDTO(category *entities.Category) *CategoryDTO {
var parentID *string
if category.ParentID != nil {
id := category.ParentID.Hex()
parentID = &id
}
return &CategoryDTO{
ID: category.ID.Hex(),
SpaceID: category.SpaceID.Hex(),
Name: category.Name,
Description: category.Description,
ParentID: parentID,
Icon: category.Icon,
Order: category.Order,
CreatedAt: category.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: category.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ========== ERROR DTOs ==========
// ErrorResponse represents an error response
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
Code int `json:"code"`
}
// ValidationError represents a validation error
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ValidationErrorResponse represents multiple validation errors
type ValidationErrorResponse struct {
Errors []ValidationError `json:"errors"`
}

View File

@@ -0,0 +1,313 @@
package services
import (
"context"
"errors"
"strings"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
)
// AdminService handles admin-level operations
type AdminService struct {
userRepo repositories.UserRepository
groupRepo repositories.GroupRepository
spaceRepo repositories.SpaceRepository
membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository
featureFlagRepo repositories.FeatureFlagRepository
permissionService *PermissionService
}
// NewAdminService creates a new AdminService
func NewAdminService(
userRepo repositories.UserRepository,
groupRepo repositories.GroupRepository,
spaceRepo repositories.SpaceRepository,
membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository,
featureFlagRepo repositories.FeatureFlagRepository,
permissionService *PermissionService,
) *AdminService {
return &AdminService{
userRepo: userRepo,
groupRepo: groupRepo,
spaceRepo: spaceRepo,
membershipRepo: membershipRepo,
noteRepo: noteRepo,
categoryRepo: categoryRepo,
featureFlagRepo: featureFlagRepo,
permissionService: permissionService,
}
}
// ListUsers returns all users as admin DTOs
func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) {
users, err := s.userRepo.ListAllUsers(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.AdminUserDTO, len(users))
for i, u := range users {
if s.permissionService != nil {
permissions, err := s.permissionService.GetUserEffectivePermissions(ctx, u)
if err == nil {
u.Permissions = permissions
}
}
result[i] = dto.NewAdminUserDTO(u)
}
return result, nil
}
// ListGroups returns all permission groups.
func (s *AdminService) ListGroups(ctx context.Context) ([]*dto.PermissionGroupDTO, error) {
groups, err := s.groupRepo.ListGroups(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.PermissionGroupDTO, len(groups))
for i, group := range groups {
result[i] = dto.NewPermissionGroupDTO(group)
}
return result, nil
}
// CreateGroup creates a new permission group.
func (s *AdminService) CreateGroup(ctx context.Context, req *dto.CreatePermissionGroupRequest) (*dto.PermissionGroupDTO, error) {
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, errors.New("group name is required")
}
group := &entities.PermissionGroup{
Name: name,
Description: strings.TrimSpace(req.Description),
Permissions: normalizePermissions(req.Permissions),
IsSystem: false,
}
if err := s.groupRepo.CreateGroup(ctx, group); err != nil {
return nil, err
}
return dto.NewPermissionGroupDTO(group), nil
}
// UpdateGroup updates a permission group.
func (s *AdminService) UpdateGroup(ctx context.Context, groupID bson.ObjectID, req *dto.UpdatePermissionGroupRequest) (*dto.PermissionGroupDTO, error) {
group, err := s.groupRepo.GetGroupByID(ctx, groupID)
if err != nil {
return nil, err
}
if group.IsSystem {
return nil, errors.New("system groups cannot be modified")
}
if name := strings.TrimSpace(req.Name); name != "" {
group.Name = name
}
group.Description = strings.TrimSpace(req.Description)
group.Permissions = normalizePermissions(req.Permissions)
if err := s.groupRepo.UpdateGroup(ctx, group); err != nil {
return nil, err
}
if err := s.refreshAllUserPermissions(ctx); err != nil {
return nil, err
}
return dto.NewPermissionGroupDTO(group), nil
}
// UpdateUserGroups assigns groups to a user.
func (s *AdminService) UpdateUserGroups(ctx context.Context, userID bson.ObjectID, groupIDs []bson.ObjectID) (*dto.AdminUserDTO, error) {
if s.permissionService == nil {
return nil, errors.New("permission service unavailable")
}
user, err := s.permissionService.SetUserGroups(ctx, userID, groupIDs)
if err != nil {
return nil, err
}
return dto.NewAdminUserDTO(user), nil
}
func (s *AdminService) refreshAllUserPermissions(ctx context.Context) error {
if s.permissionService == nil {
return nil
}
users, err := s.userRepo.ListAllUsers(ctx)
if err != nil {
return err
}
for _, user := range users {
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
return err
}
}
return nil
}
func normalizePermissions(permissions []string) []string {
unique := map[string]struct{}{}
result := make([]string, 0, len(permissions))
for _, permission := range permissions {
normalized := entities.NormalizePermission(permission)
if normalized == "" {
continue
}
if _, exists := unique[normalized]; exists {
continue
}
unique[normalized] = struct{}{}
result = append(result, normalized)
}
return result
}
// ListAllSpaces returns all spaces
func (s *AdminService) ListAllSpaces(ctx context.Context) ([]*dto.SpaceDTO, error) {
spaces, err := s.spaceRepo.GetAllSpaces(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.SpaceDTO, len(spaces))
for i, space := range spaces {
result[i] = dto.NewSpaceDTO(space)
}
return result, nil
}
// UpdateSpace updates all editable space fields
func (s *AdminService) UpdateSpace(ctx context.Context, spaceID bson.ObjectID, req *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, err
}
space.Name = req.Name
space.Description = req.Description
space.Icon = req.Icon
space.IsPublic = req.IsPublic
if err := s.spaceRepo.UpdateSpace(ctx, space); err != nil {
return nil, err
}
return dto.NewSpaceDTO(space), nil
}
// SetSpaceVisibility sets the is_public flag on a space
func (s *AdminService) SetSpaceVisibility(ctx context.Context, spaceID bson.ObjectID, isPublic bool) error {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return err
}
space.IsPublic = isPublic
return s.spaceRepo.UpdateSpace(ctx, space)
}
// AddSpaceMember adds a member in a space if not already present.
func (s *AdminService) AddSpaceMember(ctx context.Context, spaceID, userID bson.ObjectID) error {
existing, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err == nil && existing != nil {
return nil
}
return s.membershipRepo.CreateMembership(ctx, &entities.Membership{
UserID: userID,
SpaceID: spaceID,
})
}
// ListSpaceMembers returns all members for a space
func (s *AdminService) ListSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*dto.SpaceMemberDTO, error) {
memberships, err := s.membershipRepo.GetSpaceMembers(ctx, spaceID)
if err != nil {
return nil, err
}
result := make([]*dto.SpaceMemberDTO, 0, len(memberships))
for _, member := range memberships {
username := member.UserID.Hex()
if user, err := s.userRepo.GetUserByID(ctx, member.UserID); err == nil {
username = user.Username
}
result = append(result, &dto.SpaceMemberDTO{
UserID: member.UserID.Hex(),
Username: username,
JoinedAt: member.JoinedAt.Format("2006-01-02T15:04:05Z"),
})
}
return result, nil
}
// RemoveSpaceMember removes a member from a space.
func (s *AdminService) RemoveSpaceMember(ctx context.Context, spaceID, userID bson.ObjectID) error {
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return err
}
return s.membershipRepo.DeleteMembership(ctx, membership.ID)
}
// DeleteSpace deletes a space and all associated data (admin, no permission check).
func (s *AdminService) DeleteSpace(ctx context.Context, spaceID bson.ObjectID) error {
if err := s.noteRepo.DeleteNotesBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
return err
}
return s.spaceRepo.DeleteSpace(ctx, spaceID)
}
// GetFeatureFlags returns current app-wide feature flags.
func (s *AdminService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
if s.featureFlagRepo == nil {
return dto.NewFeatureFlagsDTO(nil), nil
}
flags, err := s.featureFlagRepo.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
return dto.NewFeatureFlagsDTO(flags), nil
}
// UpdateFeatureFlags updates app-wide feature flags.
func (s *AdminService) UpdateFeatureFlags(ctx context.Context, req *dto.UpdateFeatureFlagsRequest) (*dto.FeatureFlagsDTO, error) {
if s.featureFlagRepo == nil {
return nil, errors.New("feature flags are unavailable")
}
flags := &entities.FeatureFlags{
RegistrationEnabled: req.RegistrationEnabled,
ProviderLoginEnabled: req.ProviderLoginEnabled,
PublicSharingEnabled: req.PublicSharingEnabled,
}
if err := s.featureFlagRepo.UpdateFeatureFlags(ctx, flags); err != nil {
return nil, err
}
return dto.NewFeatureFlagsDTO(flags), nil
}

View File

@@ -0,0 +1,592 @@
package services
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
"github.com/noteapp/backend/internal/infrastructure/auth"
"github.com/noteapp/backend/internal/infrastructure/security"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/oauth2"
)
// AuthService handles authentication operations
type AuthService struct {
userRepo repositories.UserRepository
groupRepo repositories.GroupRepository
providerRepo repositories.AuthProviderRepository
linkRepo repositories.UserProviderLinkRepository
recoveryRepo repositories.AccountRecoveryRepository
featureFlagRepo repositories.FeatureFlagRepository
permissionService *PermissionService
jwtManager *auth.JWTManager
passHasher *security.PasswordHasher
encryptor *security.Encryptor
}
// NewAuthService creates a new auth service
func NewAuthService(
userRepo repositories.UserRepository,
groupRepo repositories.GroupRepository,
providerRepo repositories.AuthProviderRepository,
linkRepo repositories.UserProviderLinkRepository,
recoveryRepo repositories.AccountRecoveryRepository,
featureFlagRepo repositories.FeatureFlagRepository,
permissionService *PermissionService,
jwtManager *auth.JWTManager,
passHasher *security.PasswordHasher,
encryptor *security.Encryptor,
) *AuthService {
return &AuthService{
userRepo: userRepo,
groupRepo: groupRepo,
providerRepo: providerRepo,
linkRepo: linkRepo,
recoveryRepo: recoveryRepo,
featureFlagRepo: featureFlagRepo,
permissionService: permissionService,
jwtManager: jwtManager,
passHasher: passHasher,
encryptor: encryptor,
}
}
// Register registers a new user
func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*dto.LoginResponse, error) {
flags, err := s.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
if !flags.RegistrationEnabled {
return nil, errors.New("registration is currently disabled")
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Username = strings.TrimSpace(req.Username)
// Check if email already exists
_, err = s.userRepo.GetUserByEmail(ctx, req.Email)
if err == nil {
return nil, errors.New("email already registered")
}
// Check if username already exists
_, err = s.userRepo.GetUserByUsername(ctx, req.Username)
if err == nil {
return nil, errors.New("username already taken")
}
// Hash password
hashedPassword, err := s.passHasher.HashPassword(req.Password)
if err != nil {
return nil, err
}
// Create user
user := &entities.User{
Email: req.Email,
Username: req.Username,
PasswordHash: hashedPassword,
FirstName: req.FirstName,
LastName: req.LastName,
IsActive: true,
EmailVerified: false, // Should verify email in production
}
if err := s.userRepo.CreateUser(ctx, user); err != nil {
return nil, err
}
if s.permissionService != nil {
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
return nil, err
}
}
// Generate tokens
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
if err != nil {
return nil, err
}
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
if err != nil {
return nil, err
}
return &dto.LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: dto.NewUserDTO(user),
ExpiresIn: 3600, // 1 hour
}, nil
}
// Login authenticates a user
func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest) (*dto.LoginResponse, error) {
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Get user by email
user, err := s.userRepo.GetUserByEmail(ctx, req.Email)
if err != nil {
return nil, errors.New("invalid credentials")
}
if !user.IsActive {
return nil, errors.New("account is inactive")
}
// Verify password
match, err := s.passHasher.VerifyPassword(req.Password, user.PasswordHash)
if err != nil || !match {
return nil, errors.New("invalid credentials")
}
// Update last login
now := time.Now()
user.LastLoginAt = &now
if s.permissionService != nil {
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
return nil, err
}
}
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
// Log error but don't fail the login
}
// Generate tokens
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
if err != nil {
return nil, err
}
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
if err != nil {
return nil, err
}
return &dto.LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: dto.NewUserDTO(user),
ExpiresIn: 3600,
}, nil
}
// RefreshAccessToken refreshes an access token
func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) {
claims, err := s.jwtManager.VerifyRefreshToken(refreshToken)
if err != nil {
return "", err
}
user, err := s.userRepo.GetUserByID(ctx, mustParseObjectID(claims.UserID))
if err != nil {
return "", err
}
return s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
}
// RequestPasswordReset initiates password reset flow
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
user, err := s.userRepo.GetUserByEmail(ctx, email)
if err != nil {
// Don't reveal if email exists (security best practice)
return nil
}
token, err := auth.GenerateRandomToken(32)
if err != nil {
return err
}
recovery := &entities.AccountRecovery{
UserID: user.ID,
Token: token,
Type: "password_reset",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
// Save recovery token
// This would need AccountRecoveryRepository implementation
_ = recovery
// In production: send email with reset link containing token
return nil
}
// mustParseObjectID parses a string to ObjectID, panics on error
func mustParseObjectID(id string) bson.ObjectID {
objID, _ := bson.ObjectIDFromHex(id)
return objID
}
// ListProviders returns all active OAuth/OIDC providers.
func (s *AuthService) ListProviders(ctx context.Context) ([]*dto.AuthProviderDTO, error) {
flags, err := s.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
if !flags.ProviderLoginEnabled {
return []*dto.AuthProviderDTO{}, nil
}
if s.providerRepo == nil {
return []*dto.AuthProviderDTO{}, nil
}
providers, err := s.providerRepo.GetAllProviders(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.AuthProviderDTO, 0, len(providers))
for _, provider := range providers {
result = append(result, dto.NewAuthProviderDTO(provider))
}
return result, nil
}
// GetFeatureFlags returns current app-wide feature flags.
func (s *AuthService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
if s.featureFlagRepo == nil {
return dto.NewFeatureFlagsDTO(nil), nil
}
flags, err := s.featureFlagRepo.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
return dto.NewFeatureFlagsDTO(flags), nil
}
// CreateProvider stores a new OAuth/OIDC provider.
func (s *AuthService) CreateProvider(ctx context.Context, req *dto.CreateAuthProviderRequest) (*dto.AuthProviderDTO, error) {
if s.providerRepo == nil || s.encryptor == nil {
return nil, errors.New("provider configuration unavailable")
}
providerType := strings.ToLower(strings.TrimSpace(req.Type))
if providerType != "oidc" && providerType != "oauth2" {
return nil, errors.New("provider type must be oidc or oauth2")
}
name := strings.TrimSpace(req.Name)
clientID := strings.TrimSpace(req.ClientID)
clientSecret := strings.TrimSpace(req.ClientSecret)
authorizationURL := strings.TrimSpace(req.AuthorizationURL)
tokenURL := strings.TrimSpace(req.TokenURL)
if name == "" || clientID == "" || clientSecret == "" || authorizationURL == "" || tokenURL == "" {
return nil, errors.New("missing required provider fields")
}
encryptedSecret, err := s.encryptor.Encrypt(clientSecret)
if err != nil {
return nil, err
}
provider := &entities.AuthProvider{
Name: name,
Type: providerType,
ClientID: clientID,
ClientSecret: encryptedSecret,
AuthorizationURL: authorizationURL,
TokenURL: tokenURL,
UserInfoURL: strings.TrimSpace(req.UserInfoURL),
Scopes: normalizeScopes(req.Scopes, providerType),
IDTokenClaim: strings.TrimSpace(req.IDTokenClaim),
IsActive: req.IsActive,
}
if err := s.providerRepo.CreateProvider(ctx, provider); err != nil {
return nil, err
}
return dto.NewAuthProviderDTO(provider), nil
}
// BuildProviderAuthorizationURL constructs a provider authorization URL.
func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) {
flags, err := s.GetFeatureFlags(ctx)
if err != nil {
return "", err
}
if !flags.ProviderLoginEnabled {
return "", errors.New("provider login is currently disabled")
}
provider, secret, err := s.getProviderConfig(ctx, providerID)
if err != nil {
return "", err
}
config := oauth2.Config{
ClientID: provider.ClientID,
ClientSecret: secret,
RedirectURL: redirectURI,
Scopes: normalizeScopes(provider.Scopes, provider.Type),
Endpoint: oauth2.Endpoint{
AuthURL: provider.AuthorizationURL,
TokenURL: provider.TokenURL,
},
}
return config.AuthCodeURL(state, oauth2.AccessTypeOffline), nil
}
// CompleteProviderLogin exchanges an auth code and creates a user session.
func (s *AuthService) CompleteProviderLogin(ctx context.Context, providerID bson.ObjectID, code, redirectURI string) (*dto.LoginResponse, error) {
if s.providerRepo == nil || s.linkRepo == nil {
return nil, errors.New("provider login unavailable")
}
flags, err := s.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
if !flags.ProviderLoginEnabled {
return nil, errors.New("provider login is currently disabled")
}
provider, secret, err := s.getProviderConfig(ctx, providerID)
if err != nil {
return nil, err
}
config := oauth2.Config{
ClientID: provider.ClientID,
ClientSecret: secret,
RedirectURL: redirectURI,
Scopes: normalizeScopes(provider.Scopes, provider.Type),
Endpoint: oauth2.Endpoint{
AuthURL: provider.AuthorizationURL,
TokenURL: provider.TokenURL,
},
}
token, err := config.Exchange(ctx, code)
if err != nil {
return nil, err
}
profile, err := s.fetchProviderProfile(ctx, provider, token.AccessToken, token.Extra(provider.IDTokenClaim))
if err != nil {
return nil, err
}
user, err := s.findOrCreateOAuthUser(ctx, provider, profile)
if err != nil {
return nil, err
}
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
if err != nil {
return nil, err
}
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
if err != nil {
return nil, err
}
return &dto.LoginResponse{AccessToken: accessToken, RefreshToken: refreshToken, User: dto.NewUserDTO(user), ExpiresIn: 3600}, nil
}
type providerProfile struct {
ProviderUserID string
Email string
Username string
FirstName string
LastName string
}
func (s *AuthService) getProviderConfig(ctx context.Context, providerID bson.ObjectID) (*entities.AuthProvider, string, error) {
provider, err := s.providerRepo.GetProviderByID(ctx, providerID)
if err != nil {
return nil, "", err
}
if !provider.IsActive {
return nil, "", errors.New("provider is inactive")
}
secret, err := s.encryptor.Decrypt(provider.ClientSecret)
if err != nil {
return nil, "", err
}
return provider, secret, nil
}
func (s *AuthService) fetchProviderProfile(ctx context.Context, provider *entities.AuthProvider, accessToken string, rawIDToken any) (*providerProfile, error) {
payload := map[string]any{}
if provider.UserInfoURL != "" {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.UserInfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("provider userinfo request failed: %s", string(body))
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
} else if idToken, ok := rawIDToken.(string); ok && idToken != "" {
payload = decodeJWTWithoutVerify(idToken)
} else {
return nil, errors.New("provider must define userinfo_url or return id_token")
}
profile := &providerProfile{
ProviderUserID: firstNonEmpty(asString(payload["sub"]), asString(payload["id"]), asString(payload["user_id"])),
Email: strings.ToLower(strings.TrimSpace(firstNonEmpty(asString(payload["email"]), asString(payload["upn"])))),
Username: firstNonEmpty(asString(payload["preferred_username"]), asString(payload["login"]), asString(payload["name"])),
FirstName: firstNonEmpty(asString(payload["given_name"]), asString(payload["first_name"])),
LastName: firstNonEmpty(asString(payload["family_name"]), asString(payload["last_name"])),
}
if profile.ProviderUserID == "" {
return nil, errors.New("provider user info missing subject identifier")
}
if profile.Email == "" {
profile.Email = fmt.Sprintf("%s@%s.oauth.local", sanitizeUsername(profile.ProviderUserID), sanitizeUsername(provider.Name))
}
if profile.Username == "" {
profile.Username = strings.Split(profile.Email, "@")[0]
}
profile.Username = sanitizeUsername(profile.Username)
return profile, nil
}
func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider *entities.AuthProvider, profile *providerProfile) (*entities.User, error) {
if link, err := s.linkRepo.GetLinkByProviderUserID(ctx, provider.ID, profile.ProviderUserID); err == nil {
return s.userRepo.GetUserByID(ctx, link.UserID)
}
user, err := s.userRepo.GetUserByEmail(ctx, profile.Email)
if err != nil {
username, err := s.generateUniqueUsername(ctx, profile.Username)
if err != nil {
return nil, err
}
user = &entities.User{Email: profile.Email, Username: username, PasswordHash: "", FirstName: profile.FirstName, LastName: profile.LastName, IsActive: true, EmailVerified: true}
if err := s.userRepo.CreateUser(ctx, user); err != nil {
return nil, err
}
}
if _, err := s.linkRepo.GetLink(ctx, user.ID, provider.ID); err != nil {
if err := s.linkRepo.CreateLink(ctx, &entities.UserProviderLink{UserID: user.ID, ProviderID: provider.ID, ProviderUserID: profile.ProviderUserID, Email: profile.Email}); err != nil {
return nil, err
}
}
return user, nil
}
func (s *AuthService) generateUniqueUsername(ctx context.Context, base string) (string, error) {
base = sanitizeUsername(base)
candidates := []string{base}
for i := 0; i < 5; i++ {
token, err := auth.GenerateRandomToken(2)
if err != nil {
return "", err
}
candidates = append(candidates, fmt.Sprintf("%s-%s", base, token[:4]))
}
for _, candidate := range candidates {
if _, err := s.userRepo.GetUserByUsername(ctx, candidate); err != nil {
return candidate, nil
}
}
return fmt.Sprintf("%s-%d", base, time.Now().Unix()), nil
}
func normalizeScopes(scopes []string, providerType string) []string {
if len(scopes) == 0 {
if providerType == "oidc" {
return []string{"openid", "profile", "email"}
}
return []string{"profile", "email"}
}
result := make([]string, 0, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope != "" {
result = append(result, scope)
}
}
return result
}
func decodeJWTWithoutVerify(token string) map[string]any {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return map[string]any{}
}
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return map[string]any{}
}
claims := map[string]any{}
if err := json.Unmarshal(decoded, &claims); err != nil {
return map[string]any{}
}
return claims
}
func asString(value any) string {
if str, ok := value.(string); ok {
return strings.TrimSpace(str)
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func sanitizeUsername(value string) string {
cleaned := regexp.MustCompile(`[^a-zA-Z0-9_-]+`).ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), "-")
cleaned = strings.Trim(cleaned, "-")
if cleaned == "" {
return "user"
}
return cleaned
}

View File

@@ -0,0 +1,283 @@
package services
import (
"context"
"errors"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
)
// CategoryService handles category operations
type CategoryService struct {
categoryRepo repositories.CategoryRepository
membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository
permissionService *PermissionService
}
// NewCategoryService creates a new category service
func NewCategoryService(
categoryRepo repositories.CategoryRepository,
membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository,
permissionService *PermissionService,
) *CategoryService {
return &CategoryService{
categoryRepo: categoryRepo,
membershipRepo: membershipRepo,
noteRepo: noteRepo,
permissionService: permissionService,
}
}
// CreateCategory creates a new category
func (s *CategoryService) CreateCategory(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateCategoryRequest) (*dto.CategoryDTO, error) {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return nil, errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.create")
if permErr != nil {
return nil, permErr
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
var parentID *bson.ObjectID
if req.ParentID != nil {
id, _ := bson.ObjectIDFromHex(*req.ParentID)
parentID = &id
// Verify parent category exists and belongs to same space
parent, err := s.categoryRepo.GetCategoryByID(ctx, id)
if err != nil || parent.SpaceID != spaceID {
return nil, errors.New("invalid parent category")
}
}
// Get next order value
categories, err := s.categoryRepo.GetCategoriesBySpaceID(ctx, spaceID)
order := len(categories)
category := &entities.Category{
SpaceID: spaceID,
Name: req.Name,
Description: req.Description,
ParentID: parentID,
Icon: req.Icon,
Order: order,
CreatedBy: userID,
UpdatedBy: userID,
}
if err := s.categoryRepo.CreateCategory(ctx, category); err != nil {
return nil, err
}
return dto.NewCategoryDTO(category), nil
}
// GetCategoryTree retrieves the full tree structure for a space
func (s *CategoryService) GetCategoryTree(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.CategoryTreeDTO, error) {
// Verify user has access to space
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
// Get root categories
categories, err := s.categoryRepo.GetRootCategories(ctx, spaceID)
if err != nil {
return nil, err
}
var trees []*dto.CategoryTreeDTO
for _, category := range categories {
tree, err := s.buildCategoryTree(ctx, category, spaceID)
if err == nil {
trees = append(trees, tree)
}
}
return trees, nil
}
// buildCategoryTree recursively builds a category tree
func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entities.Category, spaceID bson.ObjectID) (*dto.CategoryTreeDTO, error) {
tree := &dto.CategoryTreeDTO{
CategoryDTO: dto.NewCategoryDTO(category),
}
// Get subcategories
subcategories, err := s.categoryRepo.GetSubcategories(ctx, category.ID)
if err == nil {
for _, subcat := range subcategories {
subtree, err := s.buildCategoryTree(ctx, subcat, spaceID)
if err == nil {
tree.Subcategories = append(tree.Subcategories, subtree)
}
}
}
// Get notes in this category
notes, err := s.noteRepo.GetNotesByCategory(ctx, spaceID, category.ID)
if err == nil {
for _, note := range notes {
tree.Notes = append(tree.Notes, dto.NewNoteListItemDTO(note))
}
}
return tree, nil
}
// UpdateCategory updates a category
func (s *CategoryService) UpdateCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, req *dto.UpdateCategoryRequest) (*dto.CategoryDTO, error) {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return nil, errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.edit")
if permErr != nil {
return nil, permErr
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
if err != nil {
return nil, err
}
// Verify category belongs to this space
if category.SpaceID != spaceID {
return nil, errors.New("category not found in this space")
}
if req.Name != "" {
category.Name = req.Name
}
if req.Description != "" {
category.Description = req.Description
}
if req.Icon != "" {
category.Icon = req.Icon
}
category.UpdatedBy = userID
if err := s.categoryRepo.UpdateCategory(ctx, category); err != nil {
return nil, err
}
return dto.NewCategoryDTO(category), nil
}
// DeleteCategory deletes a category (and optionally move notes)
func (s *CategoryService) DeleteCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, moveNotesTo *string) error {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.delete")
if permErr != nil {
return permErr
}
if !hasPermission {
return errors.New("insufficient permissions")
}
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
if err != nil {
return err
}
// Verify category belongs to this space
if category.SpaceID != spaceID {
return errors.New("category not found in this space")
}
// Handle notes in this category
notes, err := s.noteRepo.GetNotesByCategory(ctx, spaceID, categoryID)
if err == nil {
for _, note := range notes {
if moveNotesTo != nil {
targetID, _ := bson.ObjectIDFromHex(*moveNotesTo)
note.CategoryID = &targetID
s.noteRepo.UpdateNote(ctx, note)
} else {
// Move to root (no category)
note.CategoryID = nil
s.noteRepo.UpdateNote(ctx, note)
}
}
}
return s.categoryRepo.DeleteCategory(ctx, categoryID)
}
// MoveCategory moves a category to a new parent
func (s *CategoryService) MoveCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, newParentID *string) (*dto.CategoryDTO, error) {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return nil, errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.edit")
if permErr != nil {
return nil, permErr
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
if err != nil {
return nil, err
}
// Verify category belongs to this space
if category.SpaceID != spaceID {
return nil, errors.New("category not found in this space")
}
// Validate new parent
if newParentID != nil {
parentID, _ := bson.ObjectIDFromHex(*newParentID)
parent, err := s.categoryRepo.GetCategoryByID(ctx, parentID)
if err != nil || parent.SpaceID != spaceID {
return nil, errors.New("invalid parent category")
}
category.ParentID = &parentID
} else {
category.ParentID = nil
}
category.UpdatedBy = userID
category.UpdatedAt = time.Now()
if err := s.categoryRepo.UpdateCategory(ctx, category); err != nil {
return nil, err
}
return dto.NewCategoryDTO(category), nil
}
func (s *CategoryService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
if s.permissionService == nil {
return false, errors.New("permission service unavailable")
}
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
}

View File

@@ -0,0 +1,427 @@
package services
import (
"context"
"errors"
"strings"
"time"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
"github.com/noteapp/backend/internal/infrastructure/security"
"go.mongodb.org/mongo-driver/v2/bson"
)
// NoteService handles note operations
type NoteService struct {
noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository
membershipRepo repositories.MembershipRepository
revisionRepo repositories.NoteRevisionRepository
spaceRepo repositories.SpaceRepository
permissionService *PermissionService
passwordHasher *security.PasswordHasher
}
// NewNoteService creates a new note service
func NewNoteService(
noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository,
membershipRepo repositories.MembershipRepository,
revisionRepo repositories.NoteRevisionRepository,
spaceRepo repositories.SpaceRepository,
permissionService *PermissionService,
passwordHasher *security.PasswordHasher,
) *NoteService {
return &NoteService{
noteRepo: noteRepo,
categoryRepo: categoryRepo,
membershipRepo: membershipRepo,
revisionRepo: revisionRepo,
spaceRepo: spaceRepo,
permissionService: permissionService,
passwordHasher: passwordHasher,
}
}
func (s *NoteService) toDisplayNoteDTO(note *entities.Note) *dto.NoteDTO {
noteDTO := dto.NewNoteDTO(note)
if note.IsPasswordProtected {
noteDTO.Content = ""
}
return noteDTO
}
// CreateNote creates a new note
func (s *NoteService) CreateNote(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateNoteRequest) (*dto.NoteDTO, error) {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return nil, errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.create")
if permErr != nil {
return nil, permErr
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
var categoryID *bson.ObjectID
if req.CategoryID != nil {
id, _ := bson.ObjectIDFromHex(*req.CategoryID)
categoryID = &id
}
note := &entities.Note{
SpaceID: spaceID,
CategoryID: categoryID,
Title: req.Title,
Description: req.Description,
Content: req.Content,
Tags: req.Tags,
IsPinned: req.IsPinned,
IsFavorite: req.IsFavorite,
IsPublic: req.IsPublic,
CreatedBy: userID,
UpdatedBy: userID,
}
notePassword := strings.TrimSpace(req.NotePassword)
if notePassword != "" {
if len(notePassword) < 4 {
return nil, errors.New("note password must be at least 4 characters")
}
if s.passwordHasher == nil {
return nil, errors.New("password hasher unavailable")
}
hash, err := s.passwordHasher.HashPassword(notePassword)
if err != nil {
return nil, err
}
note.PasswordHash = hash
note.IsPasswordProtected = true
}
if err := s.noteRepo.CreateNote(ctx, note); err != nil {
return nil, err
}
return dto.NewNoteDTO(note), nil
}
// GetNote retrieves a note (with space authorization check)
func (s *NoteService) GetNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID) (*dto.NoteDTO, error) {
// Verify user has access to space
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return nil, err
}
// Verify note belongs to this space
if note.SpaceID != spaceID {
return nil, errors.New("note not found in this space")
}
// Update viewed time
now := time.Now()
note.ViewedAt = &now
_ = s.noteRepo.UpdateNote(ctx, note)
return s.toDisplayNoteDTO(note), nil
}
// GetNotesBySpace retrieves notes in a space
func (s *NoteService) GetNotesBySpace(ctx context.Context, spaceID, userID bson.ObjectID, skip, limit int) ([]*dto.NoteDTO, error) {
// Verify user has access to space
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
notes, err := s.noteRepo.GetNotesBySpaceID(ctx, spaceID, skip, limit)
if err != nil {
return nil, err
}
var noteDTOs []*dto.NoteDTO
for _, note := range notes {
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
}
return noteDTOs, nil
}
// GetPublicNotesBySpace returns notes for a public space without requiring auth
func (s *NoteService) GetPublicNotesBySpace(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*dto.NoteDTO, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, errors.New("space not found")
}
if !space.IsPublic {
return nil, errors.New("space is not public")
}
notes, err := s.noteRepo.GetPublicNotesBySpaceID(ctx, spaceID, skip, limit)
if err != nil {
return nil, err
}
var noteDTOs []*dto.NoteDTO
for _, note := range notes {
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
}
return noteDTOs, nil
}
// GetPublicNoteBySpaceAndID returns a single public note in a public space without requiring auth
func (s *NoteService) GetPublicNoteBySpaceAndID(ctx context.Context, spaceID, noteID bson.ObjectID) (*dto.NoteDTO, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, errors.New("space not found")
}
if !space.IsPublic {
return nil, errors.New("space is not public")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return nil, errors.New("note not found")
}
if note.SpaceID != spaceID {
return nil, errors.New("note not found")
}
if !note.IsPublic {
return nil, errors.New("note is not public")
}
return s.toDisplayNoteDTO(note), nil
}
// SearchNotes performs full-text search on notes in a space
func (s *NoteService) SearchNotes(ctx context.Context, spaceID, userID bson.ObjectID, query string) ([]*dto.NoteDTO, error) {
// Verify user has access to space
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
notes, err := s.noteRepo.SearchNotes(ctx, spaceID, query)
if err != nil {
return nil, err
}
var noteDTOs []*dto.NoteDTO
for _, note := range notes {
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
}
return noteDTOs, nil
}
// UpdateNote updates a note
func (s *NoteService) UpdateNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID, req *dto.UpdateNoteRequest) (*dto.NoteDTO, error) {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return nil, errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.edit")
if permErr != nil {
return nil, permErr
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return nil, err
}
// Verify note belongs to this space
if note.SpaceID != spaceID {
return nil, errors.New("note not found in this space")
}
// Create revision before updating
if s.revisionRepo != nil {
revision := &entities.NoteRevision{
NoteID: note.ID,
SpaceID: spaceID,
Title: note.Title,
Content: note.Content,
ChangedBy: userID,
CreatedAt: time.Now(),
}
_ = s.revisionRepo.CreateRevision(ctx, revision)
}
// Update fields
if req.Title != "" {
note.Title = req.Title
}
if req.Content != "" {
note.Content = req.Content
}
if req.Description != nil {
note.Description = *req.Description
}
if req.Tags != nil {
note.Tags = req.Tags
}
if req.CategoryID != nil {
id, _ := bson.ObjectIDFromHex(*req.CategoryID)
note.CategoryID = &id
}
if req.IsPinned != nil {
note.IsPinned = *req.IsPinned
}
if req.IsFavorite != nil {
note.IsFavorite = *req.IsFavorite
}
if req.IsPublic != nil {
note.IsPublic = *req.IsPublic
}
if req.NotePassword != nil {
if s.passwordHasher == nil {
return nil, errors.New("password hasher unavailable")
}
notePassword := strings.TrimSpace(*req.NotePassword)
if notePassword == "" {
note.PasswordHash = ""
note.IsPasswordProtected = false
} else {
if len(notePassword) < 4 {
return nil, errors.New("note password must be at least 4 characters")
}
hash, hashErr := s.passwordHasher.HashPassword(notePassword)
if hashErr != nil {
return nil, hashErr
}
note.PasswordHash = hash
note.IsPasswordProtected = true
}
}
note.UpdatedBy = userID
if err := s.noteRepo.UpdateNote(ctx, note); err != nil {
return nil, err
}
return dto.NewNoteDTO(note), nil
}
// UnlockNote verifies a protected note password and returns full note content for authenticated users.
func (s *NoteService) UnlockNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID, password string) (*dto.NoteDTO, error) {
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return nil, errors.New("note not found")
}
if note.SpaceID != spaceID {
return nil, errors.New("note not found in this space")
}
if !note.IsPasswordProtected {
return dto.NewNoteDTO(note), nil
}
if strings.TrimSpace(password) == "" {
return nil, errors.New("password is required")
}
if s.passwordHasher == nil {
return nil, errors.New("password hasher unavailable")
}
matched, verifyErr := s.passwordHasher.VerifyPassword(password, note.PasswordHash)
if verifyErr != nil || !matched {
return nil, errors.New("invalid note password")
}
now := time.Now()
note.ViewedAt = &now
_ = s.noteRepo.UpdateNote(ctx, note)
return dto.NewNoteDTO(note), nil
}
// UnlockPublicNote verifies a protected public note password and returns full note content.
func (s *NoteService) UnlockPublicNote(ctx context.Context, spaceID, noteID bson.ObjectID, password string) (*dto.NoteDTO, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, errors.New("space not found")
}
if !space.IsPublic {
return nil, errors.New("space is not public")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return nil, errors.New("note not found")
}
if note.SpaceID != spaceID || !note.IsPublic {
return nil, errors.New("note not found")
}
if !note.IsPasswordProtected {
return dto.NewNoteDTO(note), nil
}
if strings.TrimSpace(password) == "" {
return nil, errors.New("password is required")
}
if s.passwordHasher == nil {
return nil, errors.New("password hasher unavailable")
}
matched, verifyErr := s.passwordHasher.VerifyPassword(password, note.PasswordHash)
if verifyErr != nil || !matched {
return nil, errors.New("invalid note password")
}
now := time.Now()
note.ViewedAt = &now
_ = s.noteRepo.UpdateNote(ctx, note)
return dto.NewNoteDTO(note), nil
}
// DeleteNote deletes a note
func (s *NoteService) DeleteNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID) error {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.delete")
if permErr != nil {
return permErr
}
if !hasPermission {
return errors.New("insufficient permissions")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return err
}
// Verify note belongs to this space
if note.SpaceID != spaceID {
return errors.New("note not found in this space")
}
return s.noteRepo.DeleteNote(ctx, noteID)
}
func (s *NoteService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
if s.permissionService == nil {
return false, errors.New("permission service unavailable")
}
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
}

View File

@@ -0,0 +1,174 @@
package services
import (
"context"
"errors"
"strings"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
"go.mongodb.org/mongo-driver/v2/bson"
)
const adminGroupName = "Admin"
// PermissionService resolves and checks user permissions.
type PermissionService struct {
userRepo repositories.UserRepository
groupRepo repositories.GroupRepository
membershipRepo repositories.MembershipRepository
spaceRepo repositories.SpaceRepository
}
// NewPermissionService creates a permission service.
func NewPermissionService(
userRepo repositories.UserRepository,
groupRepo repositories.GroupRepository,
membershipRepo repositories.MembershipRepository,
spaceRepo repositories.SpaceRepository,
) *PermissionService {
return &PermissionService{
userRepo: userRepo,
groupRepo: groupRepo,
membershipRepo: membershipRepo,
spaceRepo: spaceRepo,
}
}
// EnsureAdminGroup ensures the built-in Admin group exists with full wildcard access.
func (s *PermissionService) EnsureAdminGroup(ctx context.Context) error {
adminGroup, err := s.groupRepo.GetGroupByName(ctx, adminGroupName)
if err != nil {
adminGroup = &entities.PermissionGroup{
Name: adminGroupName,
Description: "System group with full access",
Permissions: []string{"*"},
IsSystem: true,
}
if createErr := s.groupRepo.CreateGroup(ctx, adminGroup); createErr != nil {
return createErr
}
}
return nil
}
// GetUserEffectivePermissions returns a deduplicated list of permissions for a user.
func (s *PermissionService) GetUserEffectivePermissions(ctx context.Context, user *entities.User) ([]string, error) {
granted := make(map[string]struct{})
groups, err := s.groupRepo.GetGroupsByIDs(ctx, user.GroupIDs)
if err != nil {
return nil, err
}
for _, group := range groups {
for _, permission := range group.Permissions {
normalized := entities.NormalizePermission(permission)
if normalized != "" {
granted[normalized] = struct{}{}
}
}
}
result := make([]string, 0, len(granted))
for permission := range granted {
result = append(result, permission)
}
return result, nil
}
// UpdateUserEffectivePermissions resolves and persists effective user permissions.
func (s *PermissionService) UpdateUserEffectivePermissions(ctx context.Context, user *entities.User) error {
permissions, err := s.GetUserEffectivePermissions(ctx, user)
if err != nil {
return err
}
user.Permissions = permissions
return s.userRepo.UpdateUser(ctx, user)
}
// SetUserGroups assigns groups to a user and refreshes permissions.
func (s *PermissionService) SetUserGroups(ctx context.Context, userID bson.ObjectID, groupIDs []bson.ObjectID) (*entities.User, error) {
user, err := s.userRepo.GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
if len(groupIDs) > 0 {
groups, err := s.groupRepo.GetGroupsByIDs(ctx, groupIDs)
if err != nil {
return nil, err
}
if len(groups) != len(groupIDs) {
return nil, errors.New("one or more groups not found")
}
}
user.GroupIDs = dedupeObjectIDs(groupIDs)
if err := s.UpdateUserEffectivePermissions(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// UserHasPermission checks if user has a concrete permission, supporting wildcards.
func (s *PermissionService) UserHasPermission(ctx context.Context, userID bson.ObjectID, permission string) (bool, error) {
user, err := s.userRepo.GetUserByID(ctx, userID)
if err != nil {
return false, err
}
return s.UserEntityHasPermission(ctx, user, permission)
}
// UserEntityHasPermission checks permission from a loaded user entity.
func (s *PermissionService) UserEntityHasPermission(ctx context.Context, user *entities.User, permission string) (bool, error) {
permission = entities.NormalizePermission(permission)
if permission == "" {
return false, nil
}
granted, err := s.GetUserEffectivePermissions(ctx, user)
if err != nil {
return false, err
}
for _, pattern := range granted {
if entities.PermissionMatches(pattern, permission) {
return true, nil
}
}
return false, nil
}
// HasSpacePermission checks a space-scoped permission action, like note.create.
func (s *PermissionService) HasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return false, err
}
action = strings.Trim(strings.ToLower(action), ". ")
if action == "" {
return false, nil
}
permission := "space." + entities.SpacePermissionToken(space.Name) + "." + action
return s.UserHasPermission(ctx, userID, permission)
}
func dedupeObjectIDs(ids []bson.ObjectID) []bson.ObjectID {
seen := map[bson.ObjectID]struct{}{}
result := make([]bson.ObjectID, 0, len(ids))
for _, id := range ids {
if id.IsZero() {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}

View File

@@ -0,0 +1,319 @@
package services
import (
"context"
"errors"
"time"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
"go.mongodb.org/mongo-driver/v2/bson"
)
// SpaceService handles space operations
type SpaceService struct {
spaceRepo repositories.SpaceRepository
membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository
userRepo repositories.UserRepository
permissionService *PermissionService
}
// NewSpaceService creates a new space service
func NewSpaceService(
spaceRepo repositories.SpaceRepository,
membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository,
userRepo repositories.UserRepository,
permissionService *PermissionService,
) *SpaceService {
return &SpaceService{
spaceRepo: spaceRepo,
membershipRepo: membershipRepo,
noteRepo: noteRepo,
categoryRepo: categoryRepo,
userRepo: userRepo,
permissionService: permissionService,
}
}
// GetPublicSpace returns a single publicly accessible space
func (s *SpaceService) GetPublicSpace(ctx context.Context, spaceID bson.ObjectID) (*dto.SpaceDTO, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, err
}
if !space.IsPublic {
return nil, errors.New("space is not public")
}
return dto.NewSpaceDTO(space), nil
}
// GetPublicSpaces returns all publicly accessible spaces
func (s *SpaceService) GetPublicSpaces(ctx context.Context) ([]*dto.SpaceDTO, error) {
spaces, err := s.spaceRepo.GetPublicSpaces(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.SpaceDTO, len(spaces))
for i, space := range spaces {
result[i] = dto.NewSpaceDTO(space)
}
return result, nil
}
// CreateSpace creates a new space owned by the user
func (s *SpaceService) CreateSpace(ctx context.Context, userID bson.ObjectID, req *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
if allowed, err := s.canCreateSpace(ctx, userID); err != nil {
return nil, err
} else if !allowed {
return nil, errors.New("insufficient permissions")
}
space := &entities.Space{
Name: req.Name,
Description: req.Description,
Icon: req.Icon,
OwnerID: userID,
IsPublic: req.IsPublic,
}
if err := s.spaceRepo.CreateSpace(ctx, space); err != nil {
return nil, err
}
// Add user as initial member
membership := &entities.Membership{
UserID: userID,
SpaceID: space.ID,
JoinedAt: time.Now(),
}
if err := s.membershipRepo.CreateMembership(ctx, membership); err != nil {
// Delete space if membership creation fails
s.spaceRepo.DeleteSpace(ctx, space.ID)
return nil, err
}
return dto.NewSpaceDTO(space), nil
}
// GetUserSpaces retrieves all spaces for a user
func (s *SpaceService) GetUserSpaces(ctx context.Context, userID bson.ObjectID) ([]*dto.SpaceDTO, error) {
// Get all memberships for the user
memberships, err := s.membershipRepo.GetUserMemberships(ctx, userID)
if err != nil {
return nil, err
}
var spaceDTOs []*dto.SpaceDTO
for _, membership := range memberships {
space, err := s.spaceRepo.GetSpaceByID(ctx, membership.SpaceID)
if err != nil {
continue // Skip spaces that can't be loaded
}
spaceDTOs = append(spaceDTOs, dto.NewSpaceDTO(space))
}
return spaceDTOs, nil
}
// GetSpaceByID gets a space by ID (with authorization check)
func (s *SpaceService) GetSpaceByID(ctx context.Context, spaceID, userID bson.ObjectID) (*dto.SpaceDTO, error) {
// Verify user has access to this space
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, err
}
return dto.NewSpaceDTO(space), nil
}
// UpdateSpace updates a space (owner only)
func (s *SpaceService) UpdateSpace(ctx context.Context, spaceID, userID bson.ObjectID, updates *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
hasPermission, err := s.hasGlobalOrSpacePermission(ctx, userID, spaceID, "space.edit", "settings.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("unauthorized")
}
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, err
}
space.Name = updates.Name
space.Description = updates.Description
space.Icon = updates.Icon
space.IsPublic = updates.IsPublic
if err := s.spaceRepo.UpdateSpace(ctx, space); err != nil {
return nil, err
}
return dto.NewSpaceDTO(space), nil
}
// DeleteSpace deletes a space (owner only)
func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.ObjectID) error {
hasPermission, err := s.hasGlobalOrSpacePermission(ctx, userID, spaceID, "space.delete", "settings.delete")
if err != nil {
return err
}
if !hasPermission {
return errors.New("unauthorized")
}
if err := s.noteRepo.DeleteNotesBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
return err
}
return s.spaceRepo.DeleteSpace(ctx, spaceID)
}
// AddMember adds a member to a space.
func (s *SpaceService) AddMember(ctx context.Context, spaceID, userID, targetUserID bson.ObjectID) error {
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
if err != nil {
return err
}
if !hasPermission {
return errors.New("unauthorized")
}
// Create membership for target user
newMembership := &entities.Membership{
UserID: targetUserID,
SpaceID: spaceID,
JoinedAt: time.Now(),
InvitedBy: userID,
}
return s.membershipRepo.CreateMembership(ctx, newMembership)
}
// RemoveMember removes a member from a space (owner only)
func (s *SpaceService) RemoveMember(ctx context.Context, spaceID, userID, targetUserID bson.ObjectID) error {
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
if err != nil {
return err
}
if !hasPermission {
return errors.New("unauthorized")
}
// Get target membership
targetMembership, err := s.membershipRepo.GetUserMembership(ctx, targetUserID, spaceID)
if err != nil {
return err
}
return s.membershipRepo.DeleteMembership(ctx, targetMembership.ID)
}
// GetSpaceMembers returns all space members (owner only)
func (s *SpaceService) GetSpaceMembers(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.SpaceMemberDTO, error) {
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.view")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("unauthorized")
}
memberships, err := s.membershipRepo.GetSpaceMembers(ctx, spaceID)
if err != nil {
return nil, err
}
result := make([]*dto.SpaceMemberDTO, 0, len(memberships))
for _, member := range memberships {
username := member.UserID.Hex()
if user, err := s.userRepo.GetUserByID(ctx, member.UserID); err == nil {
username = user.Username
}
result = append(result, &dto.SpaceMemberDTO{
UserID: member.UserID.Hex(),
Username: username,
JoinedAt: member.JoinedAt.Format("2006-01-02T15:04:05Z"),
})
}
return result, nil
}
// ListAvailableUsers returns all users for member selection (owner only)
func (s *SpaceService) ListAvailableUsers(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.UserOptionDTO, error) {
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("unauthorized")
}
users, err := s.userRepo.ListAllUsers(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.UserOptionDTO, 0, len(users))
for _, user := range users {
result = append(result, &dto.UserOptionDTO{
ID: user.ID.Hex(),
Username: user.Username,
})
}
return result, nil
}
func (s *SpaceService) canCreateSpace(ctx context.Context, userID bson.ObjectID) (bool, error) {
if s.permissionService == nil {
return false, errors.New("permission service unavailable")
}
hasPermission, err := s.permissionService.UserHasPermission(ctx, userID, "space.create")
if err != nil {
return false, err
}
return hasPermission, nil
}
func (s *SpaceService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
if s.permissionService == nil {
return false, nil
}
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
}
func (s *SpaceService) hasGlobalOrSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, globalPermission, spaceAction string) (bool, error) {
if s.permissionService == nil {
return false, nil
}
hasGlobalPermission, err := s.permissionService.UserHasPermission(ctx, userID, globalPermission)
if err != nil {
return false, err
}
if hasGlobalPermission {
return true, nil
}
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, spaceAction)
}

View File

@@ -0,0 +1,51 @@
package entities
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// AuthProvider represents a configured OAuth/OIDC provider
type AuthProvider struct {
ID bson.ObjectID `bson:"_id,omitempty"`
Name string `bson:"name"`
Type string `bson:"type"` // "oidc", "oauth2"
ClientID string `bson:"client_id"`
ClientSecret string `bson:"client_secret"` // Encrypted in DB
AuthorizationURL string `bson:"authorization_url"`
TokenURL string `bson:"token_url"`
UserInfoURL string `bson:"userinfo_url"`
Scopes []string `bson:"scopes"`
IDTokenClaim string `bson:"id_token_claim,omitempty"`
IsActive bool `bson:"is_active"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
// LoginAttempt tracks login attempts for brute-force protection
type LoginAttempt struct {
ID bson.ObjectID `bson:"_id,omitempty"`
Email string `bson:"email"`
IPAddress string `bson:"ip_address"`
Success bool `bson:"success"`
Reason string `bson:"reason,omitempty"`
CreatedAt time.Time `bson:"created_at"`
ExpiresAt time.Time `bson:"expires_at"`
}
// FeatureFlags controls app-wide behavior toggles.
type FeatureFlags struct {
RegistrationEnabled bool `bson:"registration_enabled"`
ProviderLoginEnabled bool `bson:"provider_login_enabled"`
PublicSharingEnabled bool `bson:"public_sharing_enabled"`
}
// NewDefaultFeatureFlags returns safe defaults for a new deployment.
func NewDefaultFeatureFlags() *FeatureFlags {
return &FeatureFlags{
RegistrationEnabled: true,
ProviderLoginEnabled: true,
PublicSharingEnabled: true,
}
}

View File

@@ -0,0 +1,55 @@
package entities
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Note represents a note within a space
type Note struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
CategoryID *bson.ObjectID `bson:"category_id,omitempty"`
Title string `bson:"title"`
Description string `bson:"description"`
Content string `bson:"content"`
PasswordHash string `bson:"password_hash,omitempty"`
Tags []string `bson:"tags"`
IsPinned bool `bson:"is_pinned"`
IsFavorite bool `bson:"is_favorite"`
IsPublic bool `bson:"is_public"`
IsPasswordProtected bool `bson:"is_password_protected"`
CreatedBy bson.ObjectID `bson:"created_by"`
UpdatedBy bson.ObjectID `bson:"updated_by"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
ViewedAt *time.Time `bson:"viewed_at,omitempty"`
}
// Category represents a folder/category within a space
type Category struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
Name string `bson:"name"`
Description string `bson:"description,omitempty"`
ParentID *bson.ObjectID `bson:"parent_id,omitempty"`
Icon string `bson:"icon,omitempty"`
Order int `bson:"order"`
CreatedBy bson.ObjectID `bson:"created_by"`
UpdatedBy bson.ObjectID `bson:"updated_by"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
// NoteRevision represents a historical version of a note
type NoteRevision struct {
ID bson.ObjectID `bson:"_id,omitempty"`
NoteID bson.ObjectID `bson:"note_id"`
SpaceID bson.ObjectID `bson:"space_id"`
Title string `bson:"title"`
Content string `bson:"content"`
ChangedBy bson.ObjectID `bson:"changed_by"`
CreatedAt time.Time `bson:"created_at"`
ChangeRef string `bson:"change_ref,omitempty"`
}

View File

@@ -0,0 +1,83 @@
package entities
import (
"regexp"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
var permissionTokenSanitizer = regexp.MustCompile(`[^a-z0-9_-]+`)
// PermissionGroup represents a named group of permissions.
type PermissionGroup struct {
ID bson.ObjectID `bson:"_id,omitempty"`
Name string `bson:"name"`
NameKey string `bson:"name_key"`
Description string `bson:"description,omitempty"`
Permissions []string `bson:"permissions"`
IsSystem bool `bson:"is_system"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
// NormalizePermission lowercases and trims a permission string.
func NormalizePermission(permission string) string {
return strings.ToLower(strings.TrimSpace(permission))
}
// SpacePermissionToken converts a space name to a dot-safe permission token.
func SpacePermissionToken(spaceName string) string {
normalized := strings.ToLower(strings.TrimSpace(spaceName))
normalized = strings.ReplaceAll(normalized, " ", "_")
normalized = permissionTokenSanitizer.ReplaceAllString(normalized, "_")
normalized = strings.Trim(normalized, "_")
if normalized == "" {
return "space"
}
return normalized
}
// PermissionMatches reports whether a wildcard pattern matches a concrete permission.
func PermissionMatches(pattern, permission string) bool {
pattern = NormalizePermission(pattern)
permission = NormalizePermission(permission)
if pattern == "" || permission == "" {
return false
}
if pattern == "*" || pattern == permission {
return true
}
if !strings.Contains(pattern, "*") {
return false
}
parts := strings.Split(pattern, "*")
remaining := permission
if parts[0] != "" {
if !strings.HasPrefix(remaining, parts[0]) {
return false
}
remaining = remaining[len(parts[0]):]
}
for i := 1; i < len(parts); i++ {
part := parts[i]
if part == "" {
continue
}
idx := strings.Index(remaining, part)
if idx < 0 {
return false
}
remaining = remaining[idx+len(part):]
}
if parts[len(parts)-1] != "" {
return strings.HasSuffix(permission, parts[len(parts)-1])
}
return true
}

View File

@@ -0,0 +1,41 @@
package entities
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Space represents a top-level container for notes and categories
type Space struct {
ID bson.ObjectID `bson:"_id,omitempty"`
Name string `bson:"name"`
Description string `bson:"description,omitempty"`
Icon string `bson:"icon,omitempty"`
OwnerID bson.ObjectID `bson:"owner_id"`
IsPublic bool `bson:"is_public"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
// Membership represents a user's membership in a space
type Membership struct {
ID bson.ObjectID `bson:"_id,omitempty"`
UserID bson.ObjectID `bson:"user_id"`
SpaceID bson.ObjectID `bson:"space_id"`
JoinedAt time.Time `bson:"joined_at"`
InvitedBy bson.ObjectID `bson:"invited_by,omitempty"`
InvitedAt *time.Time `bson:"invited_at,omitempty"`
}
// SpaceInvitation represents an invitation to join a space
type SpaceInvitation struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
Email string `bson:"email"`
Token string `bson:"token"`
CreatedBy bson.ObjectID `bson:"created_by"`
CreatedAt time.Time `bson:"created_at"`
ExpiresAt time.Time `bson:"expires_at"`
AcceptedAt *time.Time `bson:"accepted_at,omitempty"`
}

View File

@@ -0,0 +1,51 @@
package entities
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// User represents a system user
type User struct {
ID bson.ObjectID `bson:"_id,omitempty"`
Email string `bson:"email"`
Username string `bson:"username"`
PasswordHash string `bson:"password_hash"`
FirstName string `bson:"first_name"`
LastName string `bson:"last_name"`
Avatar string `bson:"avatar,omitempty"`
GroupIDs []bson.ObjectID `bson:"group_ids,omitempty"`
Permissions []string `bson:"permissions,omitempty"`
IsActive bool `bson:"is_active"`
EmailVerified bool `bson:"email_verified"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
LastLoginAt *time.Time `bson:"last_login_at,omitempty"`
}
// UserProviderLink links external OAuth/OIDC providers to a user
type UserProviderLink struct {
ID bson.ObjectID `bson:"_id,omitempty"`
UserID bson.ObjectID `bson:"user_id"`
ProviderID bson.ObjectID `bson:"provider_id"`
ProviderUserID string `bson:"provider_user_id"`
Email string `bson:"email"`
ProfileData map[string]any `bson:"profile_data,omitempty"`
AccessToken string `bson:"access_token"` // Consider encrypting in production
RefreshToken string `bson:"refresh_token,omitempty"`
AccessTokenExp *time.Time `bson:"access_token_exp,omitempty"`
LinkedAt time.Time `bson:"linked_at"`
LastUsedAt *time.Time `bson:"last_used_at,omitempty"`
}
// AccountRecovery represents account recovery tokens
type AccountRecovery struct {
ID bson.ObjectID `bson:"_id,omitempty"`
UserID bson.ObjectID `bson:"user_id"`
Token string `bson:"token"`
Type string `bson:"type"` // "password_reset", "email_verification"
ExpiresAt time.Time `bson:"expires_at"`
UsedAt *time.Time `bson:"used_at,omitempty"`
CreatedAt time.Time `bson:"created_at"`
}

View File

@@ -0,0 +1,40 @@
package repositories
import (
"context"
"github.com/noteapp/backend/internal/domain/entities"
"go.mongodb.org/mongo-driver/v2/bson"
)
// AccountRecoveryRepository defines account recovery operations
type AccountRecoveryRepository interface {
CreateRecovery(ctx context.Context, recovery *entities.AccountRecovery) error
GetRecoveryByToken(ctx context.Context, token string) (*entities.AccountRecovery, error)
MarkRecoveryUsed(ctx context.Context, id bson.ObjectID) error
}
// FeatureFlagRepository defines app feature-flag operations.
type FeatureFlagRepository interface {
GetFeatureFlags(ctx context.Context) (*entities.FeatureFlags, error)
UpdateFeatureFlags(ctx context.Context, flags *entities.FeatureFlags) error
}
// Additional repository extensions
type (
// SpaceRepository extensions
SpaceRepositoryExt interface {
SpaceRepository
}
// MembershipRepository extensions
MembershipRepositoryExt interface {
MembershipRepository
GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error)
}
// NoteRepository extensions
NoteRepositoryExt interface {
NoteRepository
}
)

View File

@@ -0,0 +1,215 @@
package repositories
import (
"context"
"github.com/noteapp/backend/internal/domain/entities"
"go.mongodb.org/mongo-driver/v2/bson"
)
// UserRepository defines user operations
type UserRepository interface {
// CreateUser creates a new user
CreateUser(ctx context.Context, user *entities.User) error
// GetUserByID retrieves a user by ID
GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error)
// GetUserByEmail retrieves a user by email
GetUserByEmail(ctx context.Context, email string) (*entities.User, error)
// GetUserByUsername retrieves a user by username
GetUserByUsername(ctx context.Context, username string) (*entities.User, error)
// UpdateUser updates a user
UpdateUser(ctx context.Context, user *entities.User) error
// DeleteUser deletes a user
DeleteUser(ctx context.Context, id bson.ObjectID) error
// ListAllUsers retrieves all users (admin use)
ListAllUsers(ctx context.Context) ([]*entities.User, error)
}
// GroupRepository defines permission group operations
type GroupRepository interface {
// CreateGroup creates a new permission group
CreateGroup(ctx context.Context, group *entities.PermissionGroup) error
// GetGroupByID retrieves a group by ID
GetGroupByID(ctx context.Context, id bson.ObjectID) (*entities.PermissionGroup, error)
// GetGroupByName retrieves a group by name
GetGroupByName(ctx context.Context, name string) (*entities.PermissionGroup, error)
// GetGroupsByIDs retrieves groups by IDs
GetGroupsByIDs(ctx context.Context, ids []bson.ObjectID) ([]*entities.PermissionGroup, error)
// ListGroups retrieves all groups
ListGroups(ctx context.Context) ([]*entities.PermissionGroup, error)
// UpdateGroup updates an existing group
UpdateGroup(ctx context.Context, group *entities.PermissionGroup) error
// DeleteGroup deletes a group
DeleteGroup(ctx context.Context, id bson.ObjectID) error
}
// SpaceRepository defines space operations
type SpaceRepository interface {
// CreateSpace creates a new space
CreateSpace(ctx context.Context, space *entities.Space) error
// GetSpaceByID retrieves a space by ID
GetSpaceByID(ctx context.Context, id bson.ObjectID) (*entities.Space, error)
// GetSpacesByUserID retrieves all spaces for a user
GetSpacesByUserID(ctx context.Context, userID bson.ObjectID) ([]*entities.Space, error)
// GetAllSpaces retrieves all spaces (admin use)
GetAllSpaces(ctx context.Context) ([]*entities.Space, error)
// GetPublicSpaces retrieves all spaces marked as public
GetPublicSpaces(ctx context.Context) ([]*entities.Space, error)
// UpdateSpace updates a space
UpdateSpace(ctx context.Context, space *entities.Space) error
// DeleteSpace deletes a space
DeleteSpace(ctx context.Context, id bson.ObjectID) error
}
// MembershipRepository defines membership operations
type MembershipRepository interface {
// CreateMembership creates a new membership
CreateMembership(ctx context.Context, membership *entities.Membership) error
// GetMembershipByID retrieves a membership by ID
GetMembershipByID(ctx context.Context, id bson.ObjectID) (*entities.Membership, error)
// GetUserMembership retrieves a membership for a user in a space
GetUserMembership(ctx context.Context, userID, spaceID bson.ObjectID) (*entities.Membership, error)
// GetSpaceMembers retrieves all members in a space
GetSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Membership, error)
// GetUserMemberships retrieves all memberships for a user
GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error)
// UpdateMembership updates a membership
UpdateMembership(ctx context.Context, membership *entities.Membership) error
// DeleteMembership deletes a membership
DeleteMembership(ctx context.Context, id bson.ObjectID) error
// DeleteMembershipsBySpaceID deletes all memberships for a space
DeleteMembershipsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
}
// NoteRepository defines note operations
type NoteRepository interface {
// CreateNote creates a new note
CreateNote(ctx context.Context, note *entities.Note) error
// GetNoteByID retrieves a note by ID
GetNoteByID(ctx context.Context, id bson.ObjectID) (*entities.Note, error)
// GetNotesBySpaceID retrieves all notes in a space
GetNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error)
// GetPublicNotesBySpaceID retrieves public notes in a space
GetPublicNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error)
// GetNotesByCategory retrieves notes in a category
GetNotesByCategory(ctx context.Context, spaceID, categoryID bson.ObjectID) ([]*entities.Note, error)
// SearchNotes performs full-text search on notes
SearchNotes(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Note, error)
// UpdateNote updates a note
UpdateNote(ctx context.Context, note *entities.Note) error
// DeleteNote deletes a note
DeleteNote(ctx context.Context, id bson.ObjectID) error
// DeleteNotesBySpaceID deletes all notes in a space
DeleteNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
}
// CategoryRepository defines category operations
type CategoryRepository interface {
// CreateCategory creates a new category
CreateCategory(ctx context.Context, category *entities.Category) error
// GetCategoryByID retrieves a category by ID
GetCategoryByID(ctx context.Context, id bson.ObjectID) (*entities.Category, error)
// GetCategoriesBySpaceID retrieves all categories in a space
GetCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error)
// GetRootCategories retrieves root level categories in a space
GetRootCategories(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error)
// GetSubcategories retrieves subcategories of a category
GetSubcategories(ctx context.Context, parentID bson.ObjectID) ([]*entities.Category, error)
// UpdateCategory updates a category
UpdateCategory(ctx context.Context, category *entities.Category) error
// DeleteCategory deletes a category
DeleteCategory(ctx context.Context, id bson.ObjectID) error
// DeleteCategoriesBySpaceID deletes all categories in a space
DeleteCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
}
// AuthProviderRepository defines auth provider operations
type AuthProviderRepository interface {
// CreateProvider creates a new auth provider
CreateProvider(ctx context.Context, provider *entities.AuthProvider) error
// GetProviderByID retrieves a provider by ID
GetProviderByID(ctx context.Context, id bson.ObjectID) (*entities.AuthProvider, error)
// GetAllProviders retrieves all active providers
GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error)
// UpdateProvider updates a provider
UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error
// DeleteProvider deletes a provider
DeleteProvider(ctx context.Context, id bson.ObjectID) error
}
// UserProviderLinkRepository defines user provider link operations
type UserProviderLinkRepository interface {
// CreateLink creates a new user provider link
CreateLink(ctx context.Context, link *entities.UserProviderLink) error
// GetLink retrieves a user provider link
GetLink(ctx context.Context, userID, providerID bson.ObjectID) (*entities.UserProviderLink, error)
// GetLinkByProviderUserID retrieves a link by provider user ID
GetLinkByProviderUserID(ctx context.Context, providerID bson.ObjectID, providerUserID string) (*entities.UserProviderLink, error)
// GetUserLinks retrieves all provider links for a user
GetUserLinks(ctx context.Context, userID bson.ObjectID) ([]*entities.UserProviderLink, error)
// UpdateLink updates a provider link
UpdateLink(ctx context.Context, link *entities.UserProviderLink) error
// DeleteLink deletes a provider link
DeleteLink(ctx context.Context, id bson.ObjectID) error
}
// NoteRevisionRepository defines note revision operations
type NoteRevisionRepository interface {
// CreateRevision creates a new note revision
CreateRevision(ctx context.Context, revision *entities.NoteRevision) error
// GetRevisionsByNoteID retrieves all revisions for a note
GetRevisionsByNoteID(ctx context.Context, noteID bson.ObjectID) ([]*entities.NoteRevision, error)
// GetRevisionByID retrieves a specific revision
GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error)
}

View File

@@ -0,0 +1,145 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// JWTManager handles JWT token creation and verification
type JWTManager struct {
secretKey string
issuer string
duration time.Duration
}
// JWTClaims represents custom JWT claims
type JWTClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
jwt.RegisteredClaims
}
// RefreshTokenClaims represents refresh token claims
type RefreshTokenClaims struct {
UserID string `json:"user_id"`
jwt.RegisteredClaims
}
// NewJWTManager creates a new JWT manager
func NewJWTManager(secretKey, issuer string, duration time.Duration) *JWTManager {
return &JWTManager{
secretKey: secretKey,
issuer: issuer,
duration: duration,
}
}
// GenerateAccessToken generates a new access token
func (m *JWTManager) GenerateAccessToken(userID, email, username string) (string, error) {
claims := JWTClaims{
UserID: userID,
Email: email,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.duration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: m.issuer,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(m.secretKey))
}
// GenerateRefreshToken generates a new refresh token
func (m *JWTManager) GenerateRefreshToken(userID string) (string, error) {
claims := RefreshTokenClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour * 7)), // 7 days
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: m.issuer,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(m.secretKey))
}
// VerifyAccessToken verifies and parses an access token
func (m *JWTManager) VerifyAccessToken(tokenString string) (*JWTClaims, error) {
claims := &JWTClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(m.secretKey), nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
// VerifyRefreshToken verifies and parses a refresh token
func (m *JWTManager) VerifyRefreshToken(tokenString string) (*RefreshTokenClaims, error) {
claims := &RefreshTokenClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(m.secretKey), nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
// GenerateRandomToken generates a random token for password reset, etc.
func GenerateRandomToken(length int) (string, error) {
token := make([]byte, length)
if _, err := rand.Read(token); err != nil {
return "", err
}
return hex.EncodeToString(token), nil
}
// GeneratePKCEChallenge generates a PKCE code challenge
func GeneratePKCEChallenge() (codeVerifier, codeChallenge string, err error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", "", err
}
codeVerifier = hex.EncodeToString(bytes)
// For code_challenge, we'd need base64url encoding of SHA256(verifier)
// For simplicity in this example, using hex, but in production use base64url(sha256(verifier))
codeChallenge = codeVerifier
return
}
// GenerateStateToken generates a state token for OAuth/OIDC flows
func GenerateStateToken() (string, error) {
return GenerateRandomToken(16)
}

View File

@@ -0,0 +1,322 @@
package database
import (
"context"
"errors"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/noteapp/backend/internal/domain/entities"
)
// AccountRecoveryRepository implements account recovery operations
type AccountRecoveryRepository struct {
collection *mongo.Collection
}
type featureFlagSettings struct {
ID string `bson:"_id"`
Flags entities.FeatureFlags `bson:"flags"`
UpdatedAt time.Time `bson:"updated_at"`
}
// FeatureFlagRepository implements app-wide feature flag operations.
type FeatureFlagRepository struct {
collection *mongo.Collection
}
// NewFeatureFlagRepository creates a new feature flag repository.
func NewFeatureFlagRepository(db *mongo.Database) *FeatureFlagRepository {
return &FeatureFlagRepository{
collection: db.Collection("app_settings"),
}
}
// GetFeatureFlags returns persisted feature flags or defaults when not set.
func (r *FeatureFlagRepository) GetFeatureFlags(ctx context.Context) (*entities.FeatureFlags, error) {
var settings featureFlagSettings
err := r.collection.FindOne(ctx, bson.M{"_id": "feature_flags"}).Decode(&settings)
if err != nil {
if err == mongo.ErrNoDocuments {
return entities.NewDefaultFeatureFlags(), nil
}
return nil, err
}
flags := settings.Flags
return &flags, nil
}
// UpdateFeatureFlags persists feature flags.
func (r *FeatureFlagRepository) UpdateFeatureFlags(ctx context.Context, flags *entities.FeatureFlags) error {
if flags == nil {
flags = entities.NewDefaultFeatureFlags()
}
now := time.Now()
_, err := r.collection.UpdateOne(
ctx,
bson.M{"_id": "feature_flags"},
bson.M{
"$set": bson.M{
"flags": flags,
"updated_at": now,
},
},
options.UpdateOne().SetUpsert(true),
)
return err
}
// NewAccountRecoveryRepository creates a new recovery repository
func NewAccountRecoveryRepository(db *mongo.Database) *AccountRecoveryRepository {
return &AccountRecoveryRepository{
collection: db.Collection("account_recovery"),
}
}
// CreateRecovery creates a new recovery token
func (r *AccountRecoveryRepository) CreateRecovery(ctx context.Context, recovery *entities.AccountRecovery) error {
recovery.ID = bson.NewObjectID()
recovery.CreatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, recovery)
return err
}
// GetRecoveryByToken retrieves a recovery record by token
func (r *AccountRecoveryRepository) GetRecoveryByToken(ctx context.Context, token string) (*entities.AccountRecovery, error) {
var recovery entities.AccountRecovery
err := r.collection.FindOne(ctx, bson.M{
"token": token,
"expires_at": bson.M{"$gt": time.Now()},
"used_at": bson.M{"$exists": false},
}).Decode(&recovery)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("recovery token not found or expired")
}
return nil, err
}
return &recovery, nil
}
// MarkRecoveryUsed marks a recovery token as used
func (r *AccountRecoveryRepository) MarkRecoveryUsed(ctx context.Context, id bson.ObjectID) error {
now := time.Now()
_, err := r.collection.UpdateOne(ctx, bson.M{"_id": id}, bson.M{
"$set": bson.M{"used_at": now},
})
return err
}
// NoteRevisionRepository implements note revision operations
type NoteRevisionRepository struct {
collection *mongo.Collection
}
// NewNoteRevisionRepository creates a new revision repository
func NewNoteRevisionRepository(db *mongo.Database) *NoteRevisionRepository {
return &NoteRevisionRepository{
collection: db.Collection("note_revisions"),
}
}
// CreateRevision creates a new note revision
func (r *NoteRevisionRepository) CreateRevision(ctx context.Context, revision *entities.NoteRevision) error {
revision.ID = bson.NewObjectID()
revision.CreatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, revision)
return err
}
// GetRevisionsByNoteID retrieves all revisions for a note
func (r *NoteRevisionRepository) GetRevisionsByNoteID(ctx context.Context, noteID bson.ObjectID) ([]*entities.NoteRevision, error) {
var revisions []*entities.NoteRevision
cursor, err := r.collection.Find(ctx, bson.M{"note_id": noteID})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &revisions); err != nil {
return nil, err
}
return revisions, nil
}
// GetRevisionByID retrieves a specific revision
func (r *NoteRevisionRepository) GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error) {
var revision entities.NoteRevision
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&revision)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("revision not found")
}
return nil, err
}
return &revision, nil
}
// AuthProviderRepository implements auth provider operations
type AuthProviderRepository struct {
collection *mongo.Collection
}
// NewAuthProviderRepository creates a new provider repository
func NewAuthProviderRepository(db *mongo.Database) *AuthProviderRepository {
return &AuthProviderRepository{
collection: db.Collection("auth_providers"),
}
}
// CreateProvider creates a new provider
func (r *AuthProviderRepository) CreateProvider(ctx context.Context, provider *entities.AuthProvider) error {
provider.ID = bson.NewObjectID()
provider.CreatedAt = time.Now()
provider.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, provider)
return err
}
// GetProviderByID retrieves a provider by ID
func (r *AuthProviderRepository) GetProviderByID(ctx context.Context, id bson.ObjectID) (*entities.AuthProvider, error) {
var provider entities.AuthProvider
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&provider)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("provider not found")
}
return nil, err
}
return &provider, nil
}
// GetAllProviders retrieves all active providers
func (r *AuthProviderRepository) GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error) {
var providers []*entities.AuthProvider
cursor, err := r.collection.Find(ctx, bson.M{"is_active": true})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &providers); err != nil {
return nil, err
}
return providers, nil
}
// UpdateProvider updates a provider
func (r *AuthProviderRepository) UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error {
provider.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": provider.ID}, provider)
return err
}
// DeleteProvider deletes a provider
func (r *AuthProviderRepository) DeleteProvider(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
// UserProviderLinkRepository implements user provider link operations
type UserProviderLinkRepository struct {
collection *mongo.Collection
}
// NewUserProviderLinkRepository creates a new link repository
func NewUserProviderLinkRepository(db *mongo.Database) *UserProviderLinkRepository {
return &UserProviderLinkRepository{
collection: db.Collection("user_provider_links"),
}
}
// CreateLink creates a new user provider link
func (r *UserProviderLinkRepository) CreateLink(ctx context.Context, link *entities.UserProviderLink) error {
link.ID = bson.NewObjectID()
link.LinkedAt = time.Now()
_, err := r.collection.InsertOne(ctx, link)
return err
}
// GetLink retrieves a user provider link
func (r *UserProviderLinkRepository) GetLink(ctx context.Context, userID, providerID bson.ObjectID) (*entities.UserProviderLink, error) {
var link entities.UserProviderLink
err := r.collection.FindOne(ctx, bson.M{
"user_id": userID,
"provider_id": providerID,
}).Decode(&link)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("link not found")
}
return nil, err
}
return &link, nil
}
// GetLinkByProviderUserID retrieves a link by provider user ID
func (r *UserProviderLinkRepository) GetLinkByProviderUserID(ctx context.Context, providerID bson.ObjectID, providerUserID string) (*entities.UserProviderLink, error) {
var link entities.UserProviderLink
err := r.collection.FindOne(ctx, bson.M{
"provider_id": providerID,
"provider_user_id": providerUserID,
}).Decode(&link)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("link not found")
}
return nil, err
}
return &link, nil
}
// GetUserLinks retrieves all provider links for a user
func (r *UserProviderLinkRepository) GetUserLinks(ctx context.Context, userID bson.ObjectID) ([]*entities.UserProviderLink, error) {
var links []*entities.UserProviderLink
cursor, err := r.collection.Find(ctx, bson.M{"user_id": userID})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &links); err != nil {
return nil, err
}
return links, nil
}
// UpdateLink updates a provider link
func (r *UserProviderLinkRepository) UpdateLink(ctx context.Context, link *entities.UserProviderLink) error {
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": link.ID}, link)
return err
}
// DeleteLink deletes a provider link
func (r *UserProviderLinkRepository) DeleteLink(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}

View File

@@ -0,0 +1,92 @@
package database
import (
"context"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// Database holds all repository instances
type Database struct {
Client *mongo.Client
DB *mongo.Database
UserRepo *UserRepository
SpaceRepo *SpaceRepository
MembershipRepo *MembershipRepository
NoteRepo *NoteRepository
CategoryRepo *CategoryRepository
RevisionRepo *NoteRevisionRepository
GroupRepo *PermissionGroupRepository
ProviderRepo *AuthProviderRepository
LinkRepo *UserProviderLinkRepository
RecoveryRepo *AccountRecoveryRepository
FeatureFlagRepo *FeatureFlagRepository
}
// NewDatabase initializes a new database connection and repositories
func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) {
client, err := mongo.Connect(options.Client().ApplyURI(mongoURL))
if err != nil {
return nil, err
}
// Verify connection
if err = client.Ping(ctx, nil); err != nil {
return nil, err
}
db := client.Database("noteapp")
// Create repositories
database := &Database{
Client: client,
DB: db,
UserRepo: NewUserRepository(db),
SpaceRepo: NewSpaceRepository(db),
MembershipRepo: NewMembershipRepository(db),
NoteRepo: NewNoteRepository(db),
CategoryRepo: NewCategoryRepository(db),
RevisionRepo: NewNoteRevisionRepository(db),
GroupRepo: NewPermissionGroupRepository(db),
ProviderRepo: NewAuthProviderRepository(db),
LinkRepo: NewUserProviderLinkRepository(db),
RecoveryRepo: NewAccountRecoveryRepository(db),
FeatureFlagRepo: NewFeatureFlagRepository(db),
}
// Ensure all indexes are created
if err := database.EnsureIndexes(ctx); err != nil {
return nil, err
}
return database, nil
}
// EnsureIndexes ensures all necessary indexes are created
func (d *Database) EnsureIndexes(ctx context.Context) error {
if err := d.UserRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.SpaceRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.MembershipRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.NoteRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.GroupRepo.EnsureIndexes(ctx); err != nil {
return err
}
return nil
}
// Close closes the database connection
func (d *Database) Close(ctx context.Context) error {
return d.Client.Disconnect(ctx)
}

View File

@@ -0,0 +1,129 @@
package database
import (
"context"
"errors"
"strings"
"time"
"github.com/noteapp/backend/internal/domain/entities"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// PermissionGroupRepository implements permission group data access.
type PermissionGroupRepository struct {
collection *mongo.Collection
}
// NewPermissionGroupRepository creates a new group repository.
func NewPermissionGroupRepository(db *mongo.Database) *PermissionGroupRepository {
return &PermissionGroupRepository{collection: db.Collection("permission_groups")}
}
// CreateGroup creates a new permission group.
func (r *PermissionGroupRepository) CreateGroup(ctx context.Context, group *entities.PermissionGroup) error {
group.ID = bson.NewObjectID()
group.Name = strings.TrimSpace(group.Name)
group.NameKey = strings.ToLower(group.Name)
group.CreatedAt = time.Now()
group.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, group)
return err
}
// GetGroupByID retrieves a group by ID.
func (r *PermissionGroupRepository) GetGroupByID(ctx context.Context, id bson.ObjectID) (*entities.PermissionGroup, error) {
var group entities.PermissionGroup
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&group)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("group not found")
}
return nil, err
}
return &group, nil
}
// GetGroupByName retrieves a group by case-insensitive name.
func (r *PermissionGroupRepository) GetGroupByName(ctx context.Context, name string) (*entities.PermissionGroup, error) {
normalized := strings.ToLower(strings.TrimSpace(name))
var group entities.PermissionGroup
err := r.collection.FindOne(ctx, bson.M{"name_key": normalized}).Decode(&group)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("group not found")
}
return nil, err
}
return &group, nil
}
// GetGroupsByIDs retrieves groups by IDs.
func (r *PermissionGroupRepository) GetGroupsByIDs(ctx context.Context, ids []bson.ObjectID) ([]*entities.PermissionGroup, error) {
if len(ids) == 0 {
return []*entities.PermissionGroup{}, nil
}
cursor, err := r.collection.Find(ctx, bson.M{"_id": bson.M{"$in": ids}})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var groups []*entities.PermissionGroup
if err := cursor.All(ctx, &groups); err != nil {
return nil, err
}
return groups, nil
}
// ListGroups retrieves all groups sorted by name.
func (r *PermissionGroupRepository) ListGroups(ctx context.Context) ([]*entities.PermissionGroup, error) {
opts := options.Find().SetSort(bson.D{{Key: "name", Value: 1}})
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var groups []*entities.PermissionGroup
if err := cursor.All(ctx, &groups); err != nil {
return nil, err
}
return groups, nil
}
// UpdateGroup updates an existing group.
func (r *PermissionGroupRepository) UpdateGroup(ctx context.Context, group *entities.PermissionGroup) error {
group.UpdatedAt = time.Now()
_, err := r.collection.UpdateOne(ctx, bson.M{"_id": group.ID}, bson.M{
"$set": bson.M{
"name": strings.TrimSpace(group.Name),
"name_key": strings.ToLower(strings.TrimSpace(group.Name)),
"description": group.Description,
"permissions": group.Permissions,
"is_system": group.IsSystem,
"updated_at": group.UpdatedAt,
},
})
return err
}
// DeleteGroup deletes a group.
func (r *PermissionGroupRepository) DeleteGroup(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
// EnsureIndexes creates indexes for the permission groups collection.
func (r *PermissionGroupRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{
Keys: bson.D{{Key: "name_key", Value: 1}},
Options: options.Index().SetUnique(true),
},
})
return err
}

View File

@@ -0,0 +1,338 @@
package database
import (
"context"
"errors"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/noteapp/backend/internal/domain/entities"
)
// NoteRepository implements the note repository interface
type NoteRepository struct {
collection *mongo.Collection
}
func notePrioritySortOptions(skip, limit int) *options.FindOptionsBuilder {
return options.Find().
SetSkip(int64(skip)).
SetLimit(int64(limit)).
SetSort(bson.D{
{Key: "is_pinned", Value: -1},
{Key: "is_favorite", Value: -1},
{Key: "title", Value: 1},
}).
SetCollation(&options.Collation{Locale: "en", Strength: 2})
}
// NewNoteRepository creates a new note repository
func NewNoteRepository(db *mongo.Database) *NoteRepository {
return &NoteRepository{
collection: db.Collection("notes"),
}
}
// CreateNote creates a new note
func (r *NoteRepository) CreateNote(ctx context.Context, note *entities.Note) error {
note.ID = bson.NewObjectID()
note.CreatedAt = time.Now()
note.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, note)
return err
}
// GetNoteByID retrieves a note by ID
func (r *NoteRepository) GetNoteByID(ctx context.Context, id bson.ObjectID) (*entities.Note, error) {
var note entities.Note
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&note)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("note not found")
}
return nil, err
}
return &note, nil
}
// GetNotesBySpaceID retrieves all notes in a space with pagination
func (r *NoteRepository) GetNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error) {
var notes []*entities.Note
opts := notePrioritySortOptions(skip, limit)
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &notes); err != nil {
return nil, err
}
return notes, nil
}
// GetPublicNotesBySpaceID retrieves public notes in a space with pagination.
func (r *NoteRepository) GetPublicNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error) {
var notes []*entities.Note
opts := notePrioritySortOptions(skip, limit)
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "is_public": true}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &notes); err != nil {
return nil, err
}
return notes, nil
}
// GetNotesByCategory retrieves notes in a category
func (r *NoteRepository) GetNotesByCategory(ctx context.Context, spaceID, categoryID bson.ObjectID) ([]*entities.Note, error) {
var notes []*entities.Note
opts := options.Find().
SetSort(bson.D{
{Key: "is_pinned", Value: -1},
{Key: "is_favorite", Value: -1},
{Key: "title", Value: 1},
}).
SetCollation(&options.Collation{Locale: "en", Strength: 2})
cursor, err := r.collection.Find(ctx, bson.M{
"space_id": spaceID,
"category_id": categoryID,
}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &notes); err != nil {
return nil, err
}
return notes, nil
}
// SearchNotes performs full-text search on notes
func (r *NoteRepository) SearchNotes(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Note, error) {
var notes []*entities.Note
cursor, err := r.collection.Find(ctx, bson.M{
"space_id": spaceID,
"$text": bson.M{
"$search": query,
},
})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &notes); err != nil {
return nil, err
}
return notes, nil
}
// UpdateNote updates a note
func (r *NoteRepository) UpdateNote(ctx context.Context, note *entities.Note) error {
note.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": note.ID}, note)
return err
}
// DeleteNote deletes a note
func (r *NoteRepository) DeleteNote(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
// DeleteNotesBySpaceID deletes all notes in a space
func (r *NoteRepository) DeleteNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err
}
// EnsureIndexes creates necessary indexes
func (r *NoteRepository) EnsureIndexes(ctx context.Context) error {
indexModel := []mongo.IndexModel{
{
Keys: bson.D{bson.E{Key: "space_id", Value: 1}},
},
{
Keys: bson.D{bson.E{Key: "category_id", Value: 1}},
},
{
Keys: bson.D{
bson.E{Key: "title", Value: "text"},
bson.E{Key: "content", Value: "text"},
bson.E{Key: "tags", Value: "text"},
},
},
{
Keys: bson.D{bson.E{Key: "updated_at", Value: -1}},
},
{
Keys: bson.D{
{Key: "space_id", Value: 1},
{Key: "is_pinned", Value: -1},
{Key: "is_favorite", Value: -1},
{Key: "title", Value: 1},
},
},
{
Keys: bson.D{
{Key: "space_id", Value: 1},
{Key: "is_public", Value: 1},
{Key: "is_pinned", Value: -1},
{Key: "is_favorite", Value: -1},
{Key: "title", Value: 1},
},
},
}
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
return err
}
// ========== CATEGORY REPOSITORY ==========
// CategoryRepository implements the category repository interface
type CategoryRepository struct {
collection *mongo.Collection
}
// NewCategoryRepository creates a new category repository
func NewCategoryRepository(db *mongo.Database) *CategoryRepository {
return &CategoryRepository{
collection: db.Collection("categories"),
}
}
// CreateCategory creates a new category
func (r *CategoryRepository) CreateCategory(ctx context.Context, category *entities.Category) error {
category.ID = bson.NewObjectID()
category.CreatedAt = time.Now()
category.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, category)
return err
}
// GetCategoryByID retrieves a category by ID
func (r *CategoryRepository) GetCategoryByID(ctx context.Context, id bson.ObjectID) (*entities.Category, error) {
var category entities.Category
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&category)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("category not found")
}
return nil, err
}
return &category, nil
}
// GetCategoriesBySpaceID retrieves all categories in a space
func (r *CategoryRepository) GetCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error) {
var categories []*entities.Category
opts := options.Find().SetSort(bson.M{"order": 1})
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &categories); err != nil {
return nil, err
}
return categories, nil
}
// GetRootCategories retrieves root level categories in a space
func (r *CategoryRepository) GetRootCategories(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error) {
var categories []*entities.Category
opts := options.Find().SetSort(bson.M{"order": 1})
cursor, err := r.collection.Find(ctx, bson.M{
"space_id": spaceID,
"parent_id": nil,
}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &categories); err != nil {
return nil, err
}
return categories, nil
}
// GetSubcategories retrieves subcategories of a category
func (r *CategoryRepository) GetSubcategories(ctx context.Context, parentID bson.ObjectID) ([]*entities.Category, error) {
var categories []*entities.Category
opts := options.Find().SetSort(bson.M{"order": 1})
cursor, err := r.collection.Find(ctx, bson.M{"parent_id": parentID}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &categories); err != nil {
return nil, err
}
return categories, nil
}
// UpdateCategory updates a category
func (r *CategoryRepository) UpdateCategory(ctx context.Context, category *entities.Category) error {
category.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": category.ID}, category)
return err
}
// DeleteCategory deletes a category
func (r *CategoryRepository) DeleteCategory(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
// DeleteCategoriesBySpaceID deletes all categories in a space
func (r *CategoryRepository) DeleteCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err
}
// EnsureIndexes creates necessary indexes
func (r *CategoryRepository) EnsureIndexes(ctx context.Context) error {
indexModel := []mongo.IndexModel{
{
Keys: bson.D{bson.E{Key: "space_id", Value: 1}},
},
{
Keys: bson.D{bson.E{Key: "parent_id", Value: 1}},
},
{
Keys: bson.D{bson.E{Key: "order", Value: 1}},
},
}
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
return err
}

View File

@@ -0,0 +1,249 @@
package database
import (
"context"
"errors"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/noteapp/backend/internal/domain/entities"
)
// SpaceRepository implements the space repository interface
type SpaceRepository struct {
collection *mongo.Collection
}
// NewSpaceRepository creates a new space repository
func NewSpaceRepository(db *mongo.Database) *SpaceRepository {
return &SpaceRepository{
collection: db.Collection("spaces"),
}
}
// CreateSpace creates a new space
func (r *SpaceRepository) CreateSpace(ctx context.Context, space *entities.Space) error {
space.ID = bson.NewObjectID()
space.CreatedAt = time.Now()
space.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, space)
return err
}
// GetSpaceByID retrieves a space by ID
func (r *SpaceRepository) GetSpaceByID(ctx context.Context, id bson.ObjectID) (*entities.Space, error) {
var space entities.Space
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&space)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("space not found")
}
return nil, err
}
return &space, nil
}
// GetSpacesByUserID retrieves all spaces for a user (via memberships)
func (r *SpaceRepository) GetSpacesByUserID(ctx context.Context, userID bson.ObjectID) ([]*entities.Space, error) {
var spaces []*entities.Space
// Query spaces where user is a member
opts := options.Find().SetSort(bson.M{"created_at": -1})
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
// This would typically be joined with membership collection
// For now, returning all spaces - in production, filter by membership
if err = cursor.All(ctx, &spaces); err != nil {
return nil, err
}
return spaces, nil
}
// UpdateSpace updates a space
func (r *SpaceRepository) UpdateSpace(ctx context.Context, space *entities.Space) error {
space.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": space.ID}, space)
return err
}
// DeleteSpace deletes a space
func (r *SpaceRepository) DeleteSpace(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
// GetAllSpaces retrieves all spaces sorted by creation date descending
func (r *SpaceRepository) GetAllSpaces(ctx context.Context) ([]*entities.Space, error) {
opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}})
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var spaces []*entities.Space
if err := cursor.All(ctx, &spaces); err != nil {
return nil, err
}
return spaces, nil
}
// GetPublicSpaces retrieves all spaces marked as public
func (r *SpaceRepository) GetPublicSpaces(ctx context.Context) ([]*entities.Space, error) {
opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}})
cursor, err := r.collection.Find(ctx, bson.M{"is_public": true}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var spaces []*entities.Space
if err := cursor.All(ctx, &spaces); err != nil {
return nil, err
}
return spaces, nil
}
// EnsureIndexes creates necessary indexes
func (r *SpaceRepository) EnsureIndexes(ctx context.Context) error {
indexModel := []mongo.IndexModel{
{
Keys: bson.D{bson.E{Key: "owner_id", Value: 1}},
},
{
Keys: bson.D{bson.E{Key: "created_at", Value: -1}},
},
}
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
return err
}
// ========== MEMBERSHIP REPOSITORY ==========
// MembershipRepository implements the membership repository interface
type MembershipRepository struct {
collection *mongo.Collection
}
// NewMembershipRepository creates a new membership repository
func NewMembershipRepository(db *mongo.Database) *MembershipRepository {
return &MembershipRepository{
collection: db.Collection("memberships"),
}
}
// CreateMembership creates a new membership
func (r *MembershipRepository) CreateMembership(ctx context.Context, membership *entities.Membership) error {
membership.ID = bson.NewObjectID()
membership.JoinedAt = time.Now()
_, err := r.collection.InsertOne(ctx, membership)
return err
}
// GetMembershipByID retrieves a membership by ID
func (r *MembershipRepository) GetMembershipByID(ctx context.Context, id bson.ObjectID) (*entities.Membership, error) {
var membership entities.Membership
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&membership)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("membership not found")
}
return nil, err
}
return &membership, nil
}
// GetUserMembership retrieves a membership for a user in a space
func (r *MembershipRepository) GetUserMembership(ctx context.Context, userID, spaceID bson.ObjectID) (*entities.Membership, error) {
var membership entities.Membership
err := r.collection.FindOne(ctx, bson.M{
"user_id": userID,
"space_id": spaceID,
}).Decode(&membership)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("membership not found")
}
return nil, err
}
return &membership, nil
}
// GetSpaceMembers retrieves all members in a space
func (r *MembershipRepository) GetSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Membership, error) {
var memberships []*entities.Membership
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &memberships); err != nil {
return nil, err
}
return memberships, nil
}
// GetUserMemberships retrieves all memberships for a user
func (r *MembershipRepository) GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error) {
var memberships []*entities.Membership
cursor, err := r.collection.Find(ctx, bson.M{"user_id": userID})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &memberships); err != nil {
return nil, err
}
return memberships, nil
}
// UpdateMembership updates a membership
func (r *MembershipRepository) UpdateMembership(ctx context.Context, membership *entities.Membership) error {
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": membership.ID}, membership)
return err
}
// DeleteMembership deletes a membership
func (r *MembershipRepository) DeleteMembership(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
// DeleteMembershipsBySpaceID deletes all memberships for a space
func (r *MembershipRepository) DeleteMembershipsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err
}
// EnsureIndexes creates necessary indexes
func (r *MembershipRepository) EnsureIndexes(ctx context.Context) error {
indexModel := []mongo.IndexModel{
{
Keys: bson.D{bson.E{Key: "user_id", Value: 1}, bson.E{Key: "space_id", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{bson.E{Key: "space_id", Value: 1}},
},
}
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
return err
}

View File

@@ -0,0 +1,120 @@
package database
import (
"context"
"errors"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/noteapp/backend/internal/domain/entities"
)
// UserRepository implements the user repository interface
type UserRepository struct {
collection *mongo.Collection
}
// NewUserRepository creates a new user repository
func NewUserRepository(db *mongo.Database) *UserRepository {
return &UserRepository{
collection: db.Collection("users"),
}
}
// CreateUser creates a new user
func (r *UserRepository) CreateUser(ctx context.Context, user *entities.User) error {
user.ID = bson.NewObjectID()
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, user)
return err
}
// GetUserByID retrieves a user by ID
func (r *UserRepository) GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error) {
var user entities.User
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("user not found")
}
return nil, err
}
return &user, nil
}
// GetUserByEmail retrieves a user by email
func (r *UserRepository) GetUserByEmail(ctx context.Context, email string) (*entities.User, error) {
var user entities.User
err := r.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("user not found")
}
return nil, err
}
return &user, nil
}
// GetUserByUsername retrieves a user by username
func (r *UserRepository) GetUserByUsername(ctx context.Context, username string) (*entities.User, error) {
var user entities.User
err := r.collection.FindOne(ctx, bson.M{"username": username}).Decode(&user)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("user not found")
}
return nil, err
}
return &user, nil
}
// UpdateUser updates a user
func (r *UserRepository) UpdateUser(ctx context.Context, user *entities.User) error {
user.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": user.ID}, user)
return err
}
// DeleteUser deletes a user
func (r *UserRepository) DeleteUser(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
// ListAllUsers retrieves all users sorted by creation date descending
func (r *UserRepository) ListAllUsers(ctx context.Context) ([]*entities.User, error) {
opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}})
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var users []*entities.User
if err := cursor.All(ctx, &users); err != nil {
return nil, err
}
return users, nil
}
// EnsureIndexes creates necessary indexes for users collection
func (r *UserRepository) EnsureIndexes(ctx context.Context) error {
indexModel := []mongo.IndexModel{
{
Keys: bson.D{bson.E{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{bson.E{Key: "username", Value: 1}},
Options: options.Index().SetUnique(true),
},
}
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
return err
}

View File

@@ -0,0 +1,79 @@
package security
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
// Encryptor provides encryption/decryption for sensitive data
type Encryptor struct {
key []byte
}
// NewEncryptor creates a new encryptor with the given key
// The key must be 32 bytes for AES-256
func NewEncryptor(key string) (*Encryptor, error) {
if len(key) != 32 {
return nil, errors.New("encryption key must be 32 bytes (256 bits)")
}
return &Encryptor{
key: []byte(key),
}, nil
}
// Encrypt encrypts data using AES-256-GCM
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts data encrypted with Encrypt
func (e *Encryptor) Decrypt(ciphertext string) (string, error) {
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce := data[:nonceSize]
ciphertextBytes := data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

View File

@@ -0,0 +1,121 @@
package security
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)
// PasswordHasher provides password hashing and verification
type PasswordHasher struct {
time uint32
memory uint32
threads uint8
keyLen uint32
saltLen int
}
// NewPasswordHasher creates a new password hasher with sensible defaults
func NewPasswordHasher() *PasswordHasher {
return &PasswordHasher{
time: 1,
memory: 64 * 1024, // 64 MB
threads: 4,
keyLen: 32,
saltLen: 16,
}
}
// HashPassword hashes a password using Argon2id
// Returns hash in format "$argon2id$v=19$m=65536,t=1,p=4$salt$hash"
func (ph *PasswordHasher) HashPassword(password string) (string, error) {
salt := make([]byte, ph.saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey(
[]byte(password),
salt,
ph.time,
ph.memory,
ph.threads,
ph.keyLen,
)
hashStr := fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
19,
ph.memory,
ph.time,
ph.threads,
hex.EncodeToString(salt),
hex.EncodeToString(hash),
)
return hashStr, nil
}
// VerifyPassword verifies a password against a hash
func (ph *PasswordHasher) VerifyPassword(password, hash string) (bool, error) {
// Backward compatibility: accept legacy bcrypt hashes.
if strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return false, nil
}
return true, nil
}
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("invalid password hash format")
}
versionPart := strings.TrimPrefix(parts[2], "v=")
version, err := strconv.Atoi(versionPart)
if err != nil || version != 19 {
return false, errors.New("invalid password hash version")
}
var memory, timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, errors.New("invalid password hash parameters")
}
saltStr := parts[4]
hashStr := parts[5]
salt, err := hex.DecodeString(saltStr)
if err != nil {
return false, errors.New("invalid salt in password hash")
}
expectedHashBytes, err := hex.DecodeString(hashStr)
if err != nil {
return false, errors.New("invalid hash in password hash")
}
// Hash the input password with the extracted parameters
computedHash := argon2.IDKey(
[]byte(password),
salt,
timeCost,
memory,
threads,
uint32(len(expectedHashBytes)),
)
if subtle.ConstantTimeCompare(computedHash, expectedHashBytes) == 1 {
return true, nil
}
return false, nil
}

View File

@@ -0,0 +1,294 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
)
// AdminHandler handles admin-level HTTP requests
type AdminHandler struct {
adminService *services.AdminService
}
// NewAdminHandler creates a new AdminHandler
func NewAdminHandler(adminService *services.AdminService) *AdminHandler {
return &AdminHandler{adminService: adminService}
}
// ListUsers handles GET /admin/users
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.adminService.ListUsers(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
}
// UpdateUserGroups handles PUT /admin/users/{userId}/groups
func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) {
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
if err != nil {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
var req dto.UpdateUserGroupsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
groupIDs := make([]bson.ObjectID, 0, len(req.GroupIDs))
for _, groupID := range req.GroupIDs {
parsed, err := bson.ObjectIDFromHex(groupID)
if err != nil {
http.Error(w, "invalid group id", http.StatusBadRequest)
return
}
groupIDs = append(groupIDs, parsed)
}
user, err := h.adminService.UpdateUserGroups(r.Context(), userID, groupIDs)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
// ListGroups handles GET /admin/groups
func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) {
groups, err := h.adminService.ListGroups(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"groups": groups})
}
// CreateGroup handles POST /admin/groups
func (h *AdminHandler) CreateGroup(w http.ResponseWriter, r *http.Request) {
var req dto.CreatePermissionGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
group, err := h.adminService.CreateGroup(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(group)
}
// UpdateGroup handles PUT /admin/groups/{groupId}
func (h *AdminHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
groupID, err := bson.ObjectIDFromHex(mux.Vars(r)["groupId"])
if err != nil {
http.Error(w, "invalid group id", http.StatusBadRequest)
return
}
var req dto.UpdatePermissionGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
group, err := h.adminService.UpdateGroup(r.Context(), groupID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(group)
}
// ListAllSpaces handles GET /admin/spaces
func (h *AdminHandler) ListAllSpaces(w http.ResponseWriter, r *http.Request) {
spaces, err := h.adminService.ListAllSpaces(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"spaces": spaces})
}
// UpdateSpace handles PUT /admin/spaces/{spaceId}
func (h *AdminHandler) UpdateSpace(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
var req dto.CreateSpaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
space, err := h.adminService.UpdateSpace(r.Context(), spaceID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(space)
}
// SetSpaceVisibility handles PUT /admin/spaces/{spaceId}/visibility
func (h *AdminHandler) SetSpaceVisibility(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
var req struct {
IsPublic bool `json:"is_public"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if err := h.adminService.SetSpaceVisibility(r.Context(), spaceID, req.IsPublic); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "visibility updated"})
}
// AddSpaceMember handles POST /admin/spaces/{spaceId}/members
func (h *AdminHandler) AddSpaceMember(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
var req dto.AddSpaceMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
userID, err := bson.ObjectIDFromHex(req.UserID)
if err != nil {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
if err := h.adminService.AddSpaceMember(r.Context(), spaceID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"message": "member added"})
}
// ListSpaceMembers handles GET /admin/spaces/{spaceId}/members
func (h *AdminHandler) ListSpaceMembers(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
members, err := h.adminService.ListSpaceMembers(r.Context(), spaceID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"members": members})
}
// RemoveSpaceMember handles DELETE /admin/spaces/{spaceId}/members/{userId}
func (h *AdminHandler) RemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
if err != nil {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
if err := h.adminService.RemoveSpaceMember(r.Context(), spaceID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// DeleteSpace handles DELETE /admin/spaces/{spaceId}
func (h *AdminHandler) DeleteSpace(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
if err := h.adminService.DeleteSpace(r.Context(), spaceID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetFeatureFlags handles GET /admin/feature-flags
func (h *AdminHandler) GetFeatureFlags(w http.ResponseWriter, r *http.Request) {
flags, err := h.adminService.GetFeatureFlags(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(flags)
}
// UpdateFeatureFlags handles PUT /admin/feature-flags
func (h *AdminHandler) UpdateFeatureFlags(w http.ResponseWriter, r *http.Request) {
var req dto.UpdateFeatureFlagsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
flags, err := h.adminService.UpdateFeatureFlags(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(flags)
}

View File

@@ -0,0 +1,299 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"github.com/gorilla/mux"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
"github.com/noteapp/backend/internal/infrastructure/auth"
"go.mongodb.org/mongo-driver/v2/bson"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
authService *services.AuthService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// Register handles user registration
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req dto.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Basic validation
if req.Email == "" || req.Password == "" || req.Username == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
response, err := h.authService.Register(r.Context(), &req)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "registration is currently disabled") {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// Login handles user login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req dto.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
response, err := h.authService.Login(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// Set secure HTTP-only cookie for refresh token
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: response.RefreshToken,
Path: "/",
MaxAge: 7 * 24 * 60 * 60, // 7 days
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// Logout handles user logout
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
// Clear refresh token cookie
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: isSecureRequest(r),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"})
}
// ListProviders returns all active OAuth/OIDC providers.
func (h *AuthHandler) ListProviders(w http.ResponseWriter, r *http.Request) {
providers, err := h.authService.ListProviders(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"providers": providers})
}
// CreateProvider stores a new OAuth/OIDC provider configuration.
func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
var req dto.CreateAuthProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
provider, err := h.authService.CreateProvider(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(provider)
}
// StartProviderLogin redirects the browser to the selected provider.
func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) {
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
if err != nil {
http.Error(w, "Invalid provider ID", http.StatusBadRequest)
return
}
state, err := auth.GenerateStateToken()
if err != nil {
http.Error(w, "Failed to create OAuth state", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/",
MaxAge: 10 * 60,
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
redirectURI := buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback")
authorizationURL, err := h.authService.BuildProviderAuthorizationURL(r.Context(), providerID, redirectURI, state)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, authorizationURL, http.StatusFound)
}
// CompleteProviderLogin exchanges the authorization code and redirects back to the frontend.
func (h *AuthHandler) CompleteProviderLogin(w http.ResponseWriter, r *http.Request) {
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
if err != nil {
http.Error(w, "Invalid provider ID", http.StatusBadRequest)
return
}
stateCookie, err := r.Cookie("oauth_state")
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "Invalid OAuth state", http.StatusBadRequest)
return
}
response, err := h.authService.CompleteProviderLogin(r.Context(), providerID, r.URL.Query().Get("code"), buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback"))
if err != nil {
http.Redirect(w, r, buildFrontendLoginURL("oauth_error", err.Error(), "", nil), http.StatusFound)
return
}
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: response.RefreshToken,
Path: "/",
MaxAge: 7 * 24 * 60 * 60,
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, buildFrontendLoginURL("oauth_success", "", response.AccessToken, response.User), http.StatusFound)
}
// RefreshToken handles token refresh
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get refresh token from cookie
cookie, err := r.Cookie("refresh_token")
if err != nil {
http.Error(w, "Refresh token not found", http.StatusUnauthorized)
return
}
accessToken, err := h.authService.RefreshAccessToken(r.Context(), cookie.Value)
if err != nil {
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": accessToken,
"expires_in": 3600,
})
}
// Health check endpoint
func (h *AuthHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "healthy",
})
}
func isSecureRequest(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func buildBackendURL(r *http.Request, path string) string {
scheme := "http"
if isSecureRequest(r) {
scheme = "https"
}
return scheme + "://" + r.Host + path
}
func buildFrontendLoginURL(status, message, accessToken string, user *dto.UserDTO) string {
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "http://localhost:5173"
}
parsed, err := url.Parse(strings.TrimRight(frontendURL, "/") + "/login")
if err != nil {
return frontendURL + "/login"
}
query := parsed.Query()
if status != "" {
query.Set("status", status)
}
if message != "" {
query.Set("message", message)
}
if accessToken != "" {
query.Set("access_token", accessToken)
}
if user != nil {
payload, _ := json.Marshal(user)
query.Set("user_json", string(payload))
query.Set("user", base64.RawURLEncoding.EncodeToString(payload))
}
parsed.RawQuery = query.Encode()
return parsed.String()
}

View File

@@ -0,0 +1,212 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
"github.com/noteapp/backend/internal/interfaces/middleware"
)
// CategoryHandler handles category endpoints
type CategoryHandler struct {
categoryService *services.CategoryService
}
// NewCategoryHandler creates a new category handler
func NewCategoryHandler(categoryService *services.CategoryService) *CategoryHandler {
return &CategoryHandler{categoryService: categoryService}
}
// GetCategoryTree returns the full category tree for a space
func (h *CategoryHandler) GetCategoryTree(w http.ResponseWriter, r *http.Request) {
userID, err := getUserObjectID(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
tree, err := h.categoryService.GetCategoryTree(r.Context(), spaceID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tree)
}
// CreateCategory creates a new category in a space
func (h *CategoryHandler) CreateCategory(w http.ResponseWriter, r *http.Request) {
userID, err := getUserObjectID(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
var req dto.CreateCategoryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
category, err := h.categoryService.CreateCategory(r.Context(), spaceID, userID, &req)
if err != nil {
if err.Error() == "unauthorized" {
http.Error(w, err.Error(), http.StatusForbidden)
} else {
http.Error(w, err.Error(), http.StatusBadRequest)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(category)
}
// UpdateCategory updates a category
func (h *CategoryHandler) UpdateCategory(w http.ResponseWriter, r *http.Request) {
userID, err := getUserObjectID(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
categoryID, err := bson.ObjectIDFromHex(vars["categoryId"])
if err != nil {
http.Error(w, "invalid category id", http.StatusBadRequest)
return
}
var req dto.UpdateCategoryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
category, err := h.categoryService.UpdateCategory(r.Context(), categoryID, spaceID, userID, &req)
if err != nil {
if err.Error() == "unauthorized" {
http.Error(w, err.Error(), http.StatusForbidden)
} else {
http.Error(w, err.Error(), http.StatusNotFound)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(category)
}
// DeleteCategory deletes a category
func (h *CategoryHandler) DeleteCategory(w http.ResponseWriter, r *http.Request) {
userID, err := getUserObjectID(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
categoryID, err := bson.ObjectIDFromHex(vars["categoryId"])
if err != nil {
http.Error(w, "invalid category id", http.StatusBadRequest)
return
}
var moveNotesTo *string
if v := r.URL.Query().Get("moveNotesTo"); v != "" {
moveNotesTo = &v
}
if err := h.categoryService.DeleteCategory(r.Context(), categoryID, spaceID, userID, moveNotesTo); err != nil {
if err.Error() == "unauthorized" {
http.Error(w, err.Error(), http.StatusForbidden)
} else {
http.Error(w, err.Error(), http.StatusNotFound)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// MoveCategory moves a category to a new parent
func (h *CategoryHandler) MoveCategory(w http.ResponseWriter, r *http.Request) {
userID, err := getUserObjectID(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
categoryID, err := bson.ObjectIDFromHex(vars["categoryId"])
if err != nil {
http.Error(w, "invalid category id", http.StatusBadRequest)
return
}
var body struct {
ParentID *string `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
category, err := h.categoryService.MoveCategory(r.Context(), categoryID, spaceID, userID, body.ParentID)
if err != nil {
if err.Error() == "unauthorized" {
http.Error(w, err.Error(), http.StatusForbidden)
} else {
http.Error(w, err.Error(), http.StatusBadRequest)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(category)
}
// getUserObjectID extracts the user ObjectID from the request context
func getUserObjectID(r *http.Request) (bson.ObjectID, error) {
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok || userIDStr == "" {
return bson.NilObjectID, http.ErrNoCookie
}
return bson.ObjectIDFromHex(userIDStr)
}

View File

@@ -0,0 +1,266 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
"github.com/noteapp/backend/internal/interfaces/middleware"
)
// NoteHandler handles note endpoints
type NoteHandler struct {
noteService *services.NoteService
}
// NewNoteHandler creates a new note handler
func NewNoteHandler(noteService *services.NoteService) *NoteHandler {
return &NoteHandler{
noteService: noteService,
}
}
// CreateNote creates a new note
func (h *NoteHandler) CreateNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
var req dto.CreateNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
note, err := h.noteService.CreateNote(r.Context(), spaceObjID, userObjID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(note)
}
// GetNote retrieves a note
func (h *NoteHandler) GetNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
vars := mux.Vars(r)
spaceID := vars["spaceId"]
noteID := vars["noteId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
noteObjID, _ := bson.ObjectIDFromHex(noteID)
userObjID, _ := bson.ObjectIDFromHex(userID)
note, err := h.noteService.GetNote(r.Context(), noteObjID, spaceObjID, userObjID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}
// GetNotesBySpace retrieves notes in a space
func (h *NoteHandler) GetNotesBySpace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Pagination
skip, _ := strconv.Atoi(r.URL.Query().Get("skip"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit == 0 || limit > 100 {
limit = 20
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
notes, err := h.noteService.GetNotesBySpace(r.Context(), spaceObjID, userObjID, skip, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(notes)
}
// SearchNotes performs full-text search
func (h *NoteHandler) SearchNotes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Missing search query", http.StatusBadRequest)
return
}
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
notes, err := h.noteService.SearchNotes(r.Context(), spaceObjID, userObjID, query)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(notes)
}
// UpdateNote updates a note
func (h *NoteHandler) UpdateNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
vars := mux.Vars(r)
spaceID := vars["spaceId"]
noteID := vars["noteId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.UpdateNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
noteObjID, _ := bson.ObjectIDFromHex(noteID)
userObjID, _ := bson.ObjectIDFromHex(userID)
note, err := h.noteService.UpdateNote(r.Context(), noteObjID, spaceObjID, userObjID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}
// DeleteNote deletes a note
func (h *NoteHandler) DeleteNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
vars := mux.Vars(r)
spaceID := vars["spaceId"]
noteID := vars["noteId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
noteObjID, _ := bson.ObjectIDFromHex(noteID)
userObjID, _ := bson.ObjectIDFromHex(userID)
if err := h.noteService.DeleteNote(r.Context(), noteObjID, spaceObjID, userObjID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UnlockNote verifies a note password and returns full note content
func (h *NoteHandler) UnlockNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
vars := mux.Vars(r)
spaceID := vars["spaceId"]
noteID := vars["noteId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.UnlockNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
noteObjID, _ := bson.ObjectIDFromHex(noteID)
userObjID, _ := bson.ObjectIDFromHex(userID)
note, err := h.noteService.UnlockNote(r.Context(), noteObjID, spaceObjID, userObjID, req.Password)
if err != nil {
if err.Error() == "invalid note password" {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}

View File

@@ -0,0 +1,156 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
)
// PublicHandler handles unauthenticated public read-only requests
type PublicHandler struct {
spaceService *services.SpaceService
noteService *services.NoteService
}
// NewPublicHandler creates a new PublicHandler
func NewPublicHandler(spaceService *services.SpaceService, noteService *services.NoteService) *PublicHandler {
return &PublicHandler{spaceService: spaceService, noteService: noteService}
}
// GetPublicSpace handles GET /public/spaces/{spaceId}
func (h *PublicHandler) GetPublicSpace(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
space, err := h.spaceService.GetPublicSpace(r.Context(), spaceID)
if err != nil {
if err.Error() == "space is not public" {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(space)
}
// ListPublicSpaces handles GET /public/spaces
func (h *PublicHandler) ListPublicSpaces(w http.ResponseWriter, r *http.Request) {
spaces, err := h.spaceService.GetPublicSpaces(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"spaces": spaces})
}
// GetPublicNotes handles GET /public/spaces/{spaceId}/notes
func (h *PublicHandler) GetPublicNotes(w http.ResponseWriter, r *http.Request) {
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
skip := 0
limit := 50
if v := r.URL.Query().Get("skip"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
skip = n
}
}
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
limit = n
}
}
notes, err := h.noteService.GetPublicNotesBySpace(r.Context(), spaceID, skip, limit)
if err != nil {
if err.Error() == "space is not public" {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"notes": notes})
}
// GetPublicNote handles GET /public/spaces/{spaceId}/notes/{noteId}
func (h *PublicHandler) GetPublicNote(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(vars["noteId"])
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
note, err := h.noteService.GetPublicNoteBySpaceAndID(r.Context(), spaceID, noteID)
if err != nil {
if err.Error() == "space is not public" || err.Error() == "note is not public" || err.Error() == "space not found" || err.Error() == "note not found" {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}
// UnlockPublicNote verifies a public note password and returns full note content
func (h *PublicHandler) UnlockPublicNote(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
if err != nil {
http.Error(w, "invalid space id", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(vars["noteId"])
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
var req dto.UnlockNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
note, err := h.noteService.UnlockPublicNote(r.Context(), spaceID, noteID, req.Password)
if err != nil {
if err.Error() == "invalid note password" {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
if err.Error() == "space is not public" || err.Error() == "space not found" || err.Error() == "note not found" {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(note)
}

View File

@@ -0,0 +1,30 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/noteapp/backend/internal/application/services"
)
// SettingsHandler handles public app settings endpoints.
type SettingsHandler struct {
authService *services.AuthService
}
// NewSettingsHandler creates a new settings handler.
func NewSettingsHandler(authService *services.AuthService) *SettingsHandler {
return &SettingsHandler{authService: authService}
}
// GetFeatureFlags handles GET /api/v1/settings/feature-flags.
func (h *SettingsHandler) GetFeatureFlags(w http.ResponseWriter, r *http.Request) {
flags, err := h.authService.GetFeatureFlags(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(flags)
}

View File

@@ -0,0 +1,295 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/application/services"
"github.com/noteapp/backend/internal/interfaces/middleware"
"go.mongodb.org/mongo-driver/v2/bson"
)
// SpaceHandler handles space endpoints
type SpaceHandler struct {
spaceService *services.SpaceService
}
// NewSpaceHandler creates a new space handler
func NewSpaceHandler(spaceService *services.SpaceService) *SpaceHandler {
return &SpaceHandler{
spaceService: spaceService,
}
}
// CreateSpace creates a new space
func (h *SpaceHandler) CreateSpace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.CreateSpaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
userObjID, _ := bson.ObjectIDFromHex(userID)
space, err := h.spaceService.CreateSpace(r.Context(), userObjID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(space)
}
// GetUserSpaces retrieves all spaces for the user
func (h *SpaceHandler) GetUserSpaces(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userObjID, _ := bson.ObjectIDFromHex(userID)
spaces, err := h.spaceService.GetUserSpaces(r.Context(), userObjID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(spaces)
}
// GetSpace retrieves a space
func (h *SpaceHandler) GetSpace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
space, err := h.spaceService.GetSpaceByID(r.Context(), spaceObjID, userObjID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(space)
}
// UpdateSpace updates a space
func (h *SpaceHandler) UpdateSpace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.CreateSpaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
space, err := h.spaceService.UpdateSpace(r.Context(), spaceObjID, userObjID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(space)
}
// DeleteSpace deletes a space
func (h *SpaceHandler) DeleteSpace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
if err := h.spaceService.DeleteSpace(r.Context(), spaceObjID, userObjID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetSpaceMembers retrieves all members in a space (owner only)
func (h *SpaceHandler) GetSpaceMembers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
members, err := h.spaceService.GetSpaceMembers(r.Context(), spaceObjID, userObjID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"members": members})
}
// AddMember adds a member to a space (owner/editor)
func (h *SpaceHandler) AddMember(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.AddSpaceMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
targetUserObjID, err := bson.ObjectIDFromHex(req.UserID)
if err != nil {
http.Error(w, "Invalid user id", http.StatusBadRequest)
return
}
if err := h.spaceService.AddMember(r.Context(), spaceObjID, userObjID, targetUserObjID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"message": "member added"})
}
// RemoveMember removes a member from a space (owner/editor)
func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
targetUserID := mux.Vars(r)["userId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, err := bson.ObjectIDFromHex(spaceID)
if err != nil {
http.Error(w, "Invalid space id", http.StatusBadRequest)
return
}
userObjID, err := bson.ObjectIDFromHex(userID)
if err != nil {
http.Error(w, "Invalid user id", http.StatusBadRequest)
return
}
targetUserObjID, err := bson.ObjectIDFromHex(targetUserID)
if err != nil {
http.Error(w, "Invalid target user id", http.StatusBadRequest)
return
}
if err := h.spaceService.RemoveMember(r.Context(), spaceObjID, userObjID, targetUserObjID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetAvailableUsers returns user options for member selection (owner only)
func (h *SpaceHandler) GetAvailableUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
spaceID := mux.Vars(r)["spaceId"]
userID, err := middleware.GetUserIDFromContext(r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
userObjID, _ := bson.ObjectIDFromHex(userID)
users, err := h.spaceService.ListAvailableUsers(r.Context(), spaceObjID, userObjID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
}

View File

@@ -0,0 +1,91 @@
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"github.com/noteapp/backend/internal/infrastructure/auth"
)
// ContextKey is a custom type for context keys
type ContextKey string
const (
UserIDKey ContextKey = "user_id"
EmailKey ContextKey = "email"
UserKey ContextKey = "user"
)
// AuthMiddleware verifies JWT tokens
type AuthMiddleware struct {
jwtManager *auth.JWTManager
}
// NewAuthMiddleware creates a new auth middleware
func NewAuthMiddleware(jwtManager *auth.JWTManager) *AuthMiddleware {
return &AuthMiddleware{
jwtManager: jwtManager,
}
}
// Middleware wraps an HTTP handler with authentication
func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for login and register endpoints
if strings.HasSuffix(r.URL.Path, "/auth/login") ||
strings.HasSuffix(r.URL.Path, "/auth/register") ||
strings.HasSuffix(r.URL.Path, "/health") {
next.ServeHTTP(w, r)
return
}
// Extract token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
return
}
token := parts[1]
// Verify token
claims, err := m.jwtManager.VerifyAccessToken(token)
if err != nil {
http.Error(w, "Invalid token: "+err.Error(), http.StatusUnauthorized)
return
}
// Add claims to context
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, EmailKey, claims.Email)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
// GetUserIDFromContext extracts user ID from context
func GetUserIDFromContext(ctx context.Context) (string, error) {
userID, ok := ctx.Value(UserIDKey).(string)
if !ok {
return "", errors.New("user ID not found in context")
}
return userID, nil
}
// GetEmailFromContext extracts email from context
func GetEmailFromContext(ctx context.Context) (string, error) {
email, ok := ctx.Value(EmailKey).(string)
if !ok {
return "", errors.New("email not found in context")
}
return email, nil
}

View File

@@ -0,0 +1,91 @@
package middleware
import (
"fmt"
"net/http"
"strings"
)
// SecurityHeaders adds security headers to responses
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// HSTS - HTTP Strict Transport Security
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// CSRF Protection - same-site cookies
w.Header().Set("X-Content-Type-Options", "nosniff")
// XSS Protection
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Clickjacking protection
w.Header().Set("X-Frame-Options", "DENY")
// CSP - Content Security Policy
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: https:; "+
"font-src 'self'; "+
"connect-src 'self'; "+
"frame-ancestors 'none'")
// Referrer Policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
// RateLimitMiddleware implements basic rate limiting
type RateLimitMiddleware struct {
// In production, use a proper rate limiter like github.com/go-chi/chi/middleware
// This is a placeholder for demonstration
}
// NewRateLimitMiddleware creates a new rate limit middleware
func NewRateLimitMiddleware() *RateLimitMiddleware {
return &RateLimitMiddleware{}
}
// Middleware returns the rate limit middleware handler
func (m *RateLimitMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement proper rate limiting using distributed cache
// For now, this is a placeholder
next.ServeHTTP(w, r)
})
}
// LoggingMiddleware logs HTTP requests
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("[%s] %s %s\n", r.Method, r.RequestURI, r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
// CORSMiddleware enables CORS
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
switch {
case origin == "http://localhost", origin == "http://localhost:5173", strings.HasPrefix(origin, "http://127.0.0.1:"):
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
default:
w.Header().Set("Access-Control-Allow-Origin", "http://localhost")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}