fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed

This commit is contained in:
Stephan D
2025-11-08 00:30:29 +01:00
parent 590fad0071
commit 49b86efecb
165 changed files with 9466 additions and 0 deletions

View File

@@ -0,0 +1,409 @@
package accountserviceimp
import (
"context"
"errors"
"fmt"
"slices"
"unicode"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/auth/management"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/organization"
"github.com/tech/sendico/pkg/db/policy"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/middleware"
"github.com/tech/sendico/server/internal/mutil/flrstring"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type service struct {
logger mlogger.Logger
accountDB account.DB
orgDB organization.DB
enforcer auth.Enforcer
roleManager management.Role
config *middleware.PasswordConfig
tf transaction.Factory
policyDB policy.DB
}
func validateUserRequest(u *model.Account) error {
if u.Name == "" {
return merrors.InvalidArgument("Name must not be empty")
}
if u.Login == "" {
return merrors.InvalidArgument("Login must not be empty")
}
if u.Password == "" {
return merrors.InvalidArgument("Password must not be empty")
}
return nil
}
func (s *service) ValidatePassword(
password string,
oldPassword *string,
) error {
var hasDigit, hasUpper, hasLower, hasSpecial bool
if oldPassword != nil {
if *oldPassword == password {
return merrors.InvalidArgument("New password cannot be the same as the old password")
}
}
if len(password) < s.config.Check.MinLength {
return merrors.InvalidArgument(fmt.Sprintf("Password must be at least %d characters long", s.config.Check.MinLength))
}
// Check for digit, uppercase, lowercase, and special character
for _, char := range password {
switch {
case unicode.IsDigit(char):
hasDigit = true
case unicode.IsUpper(char):
hasUpper = true
case unicode.IsLower(char):
hasLower = true
case unicode.IsPunct(char) || unicode.IsSymbol(char):
hasSpecial = true
}
}
if s.config.Check.Digit && !hasDigit {
return merrors.InvalidArgument("Password must contain at least one digit")
}
if s.config.Check.Upper && !hasUpper {
return merrors.InvalidArgument("Password must contain at least one uppercase letter")
}
if s.config.Check.Lower && !hasLower {
return merrors.InvalidArgument("Password must contain at least one lowercase letter")
}
if s.config.Check.Special && !hasSpecial {
return merrors.InvalidArgument("Password must contain at least one special character")
}
// If all checks pass, return nil (no error)
return nil
}
func (s *service) ValidateAccount(acct *model.Account) error {
if err := validateUserRequest(acct); err != nil {
s.logger.Warn("Invalid signup acccount received", zap.Error(err), zap.String("account", acct.Login))
return err
}
if err := s.ValidatePassword(acct.Password, nil); err != nil {
s.logger.Warn("Password validation failed", zap.Error(err), zap.String("account", acct.Login))
return err
}
if err := acct.HashPassword(); err != nil {
s.logger.Warn("Failed to hash password", zap.Error(err), zap.String("account", acct.Login))
return err
}
acct.VerifyToken = flrstring.CreateRandString(s.config.TokenLength)
return nil
}
func (s *service) CreateAccount(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID primitive.ObjectID,
) error {
if org == nil {
return merrors.InvalidArgument("Organization must not be nil")
}
if acct == nil || len(acct.Login) == 0 {
return merrors.InvalidArgument("Account must have a non-empty login")
}
if roleDescID == primitive.NilObjectID {
return merrors.InvalidArgument("Role description must be provided")
}
// 1) Create the account
if err := s.accountDB.Create(ctx, acct); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
s.logger.Info("Username is already taken", zap.String("login", acct.Login))
} else {
s.logger.Warn("Failed to signup a user", zap.Error(err), zap.String("login", acct.Login))
}
return err
}
// 2) Add to organization
if err := s.JoinOrganization(ctx, org, acct, roleDescID); err != nil {
s.logger.Warn("Failed to register new organization member", zap.Error(err), mzap.StorableRef(acct))
return err
}
return nil
}
func (s *service) DeleteAccount(
ctx context.Context,
org *model.Organization,
accountRef primitive.ObjectID,
) error {
// Check if this is the only member in the organization
if len(org.Members) <= 1 {
s.logger.Warn("Cannot delete account - it's the only member in the organization",
mzap.ObjRef("account_ref", accountRef), mzap.StorableRef(org))
return merrors.InvalidArgument("Cannot delete the only member of an organization")
}
// 1) Remove from organization
if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil {
s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.ObjRef("account_ref", accountRef))
return err
}
// 2) Delete the account document
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.ObjRef("account_ref", accountRef))
return err
}
return nil
}
func (s *service) RemoveAccountFromOrganization(
ctx context.Context,
org *model.Organization,
accountRef primitive.ObjectID,
) error {
if org == nil {
return merrors.InvalidArgument("Organization must not be nil")
}
roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID)
if err != nil {
s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org),
mzap.ObjRef("account_ref", accountRef))
return err
}
for _, role := range roles {
if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil {
s.logger.Warn("Failed to revoke account role", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("role_ref", role.DescriptionRef))
return err
}
}
for i, member := range org.Members {
if member == accountRef {
// Remove the member by slicing it out
org.Members = append(org.Members[:i], org.Members[i+1:]...)
if err := s.orgDB.Update(ctx, accountRef, org); err != nil {
s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.ObjRef("account_ref", accountRef))
return err
}
break
}
}
return nil
}
func (s *service) ResetPassword(
ctx context.Context,
acct *model.Account,
) error {
acct.ResetPasswordToken = flrstring.CreateRandString(s.config.TokenLength)
return s.accountDB.Update(ctx, acct)
}
func (s *service) UpdateLogin(
ctx context.Context,
acct *model.Account,
newLogin string,
) error {
acct.EmailBackup = acct.Login
acct.Login = newLogin
acct.VerifyToken = flrstring.CreateRandString(s.config.TokenLength)
return s.accountDB.Update(ctx, acct)
}
func (s *service) JoinOrganization(
ctx context.Context,
org *model.Organization,
account *model.Account,
roleDescID primitive.ObjectID,
) error {
if slices.Contains(org.Members, account.ID) {
s.logger.Debug("Account is already a member", mzap.StorableRef(org), mzap.StorableRef(account))
return nil
}
org.Members = append(org.Members, account.ID)
if err := s.orgDB.Update(ctx, *account.GetID(), org); err != nil {
s.logger.Warn("Failed to update organization members list", zap.Error(err), mzap.StorableRef(account))
return err
}
role := &model.Role{
DescriptionRef: roleDescID,
OrganizationRef: org.ID,
AccountRef: account.ID,
}
if err := s.roleManager.Assign(ctx, role); err != nil {
s.logger.Warn("Failed to assign role to account", zap.Error(err), mzap.StorableRef(account))
return err
}
return nil
}
func (s *service) deleteOrganizationRoles(ctx context.Context, orgRef primitive.ObjectID) error {
s.logger.Info("Deleting roles for organization", mzap.ObjRef("organization_ref", orgRef))
// Get all roles for the organization
roles, err := s.roleManager.List(ctx, orgRef)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
s.logger.Warn("Failed to fetch roles for deletion", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return err
}
// Delete each role
for _, role := range roles {
if err := s.roleManager.Delete(ctx, role.ID); err != nil {
s.logger.Warn("Failed to delete role", zap.Error(err), mzap.ObjRef("role_ref", role.ID))
return err
}
}
s.logger.Info("Successfully deleted roles", zap.Int("count", len(roles)), mzap.ObjRef("organization_ref", orgRef))
return nil
}
func (s *service) deleteOrganizationPolicies(ctx context.Context, orgRef primitive.ObjectID) error {
s.logger.Info("Deleting policies for organization", mzap.ObjRef("organization_ref", orgRef))
// Get all policies for the organization
policies, err := s.policyDB.All(ctx, orgRef)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
s.logger.Warn("Failed to fetch policies for deletion", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return err
}
// Delete each policy
for _, policy := range policies {
if err := s.policyDB.Delete(ctx, policy.ID); err != nil {
s.logger.Warn("Failed to delete policy", zap.Error(err), mzap.ObjRef("policy_ref", policy.ID))
return err
}
}
s.logger.Info("Successfully deleted policies", zap.Int("count", len(policies)), mzap.ObjRef("organization_ref", orgRef))
return nil
}
func (s *service) DeleteOrganization(
ctx context.Context,
org *model.Organization,
) error {
s.logger.Info("Starting organization deletion", mzap.StorableRef(org))
// Use transaction to ensure atomicity
_, err := s.tf.CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) {
// 8. Delete all roles and role descriptions in the organization
if err := s.deleteOrganizationRoles(ctx, org.ID); err != nil {
return nil, err
}
// 9. Delete all policies in the organization
if err := s.deleteOrganizationPolicies(ctx, org.ID); err != nil {
return nil, err
}
// 10. Finally, delete the organization itself
if err := s.orgDB.Delete(ctx, primitive.NilObjectID, org.ID); err != nil {
s.logger.Warn("Failed to delete organization", zap.Error(err), mzap.StorableRef(org))
return nil, err
}
return nil, nil
})
if err != nil {
s.logger.Error("Failed to delete organization", zap.Error(err), mzap.StorableRef(org))
return err
}
s.logger.Info("Organization deleted successfully", mzap.StorableRef(org))
return nil
}
func (s *service) DeleteAll(
ctx context.Context,
org *model.Organization,
accountRef primitive.ObjectID,
) error {
s.logger.Info("Starting complete deletion (organization + account)",
mzap.StorableRef(org), mzap.ObjRef("account_ref", accountRef))
// Use transaction to ensure atomicity
_, err := s.tf.CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) {
// 1. First delete the organization and all its data
if err := s.DeleteOrganization(ctx, org); err != nil {
return nil, err
}
// 2. Then delete the account
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.ObjRef("account_ref", accountRef))
return nil, err
}
return nil, nil
})
if err != nil {
s.logger.Error("Failed to delete all data", zap.Error(err), mzap.StorableRef(org), mzap.ObjRef("account_ref", accountRef))
return err
}
s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.ObjRef("account_ref", accountRef))
return nil
}
// NewAccountService wires in your logger plus the three dependencies.
func NewAccountService(
l mlogger.Logger,
dbf db.Factory,
enforcer auth.Enforcer,
ra management.Role,
config *middleware.PasswordConfig,
) (*service, error) {
logger := l.Named("account_service")
if config == nil {
return nil, merrors.Internal("Invalid account service configuration provides")
}
res := &service{
logger: logger,
enforcer: enforcer,
roleManager: ra,
config: config,
tf: dbf.TransactionFactory(),
}
var err error
if res.accountDB, err = dbf.NewAccountDB(); err != nil {
logger.Warn("Failed to create accounts database", zap.Error(err))
return nil, err
}
if res.orgDB, err = dbf.NewOrganizationDB(); err != nil {
logger.Warn("Failed to create organizations database", zap.Error(err))
return nil, err
}
// Initialize database dependencies for cascade deletion
if res.policyDB, err = dbf.NewPoliciesDB(); err != nil {
logger.Warn("Failed to create policies database", zap.Error(err))
return nil, err
}
return res, nil
}

View File

@@ -0,0 +1,156 @@
package accountserviceimp
import (
"testing"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestDeleteAccount_Validation(t *testing.T) {
t.Run("DeleteAccount_LastMemberFails", func(t *testing.T) {
orgID := primitive.NewObjectID()
accountID := primitive.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Single Member Org"},
},
Members: []primitive.ObjectID{accountID}, // Only one member
}
org.ID = orgID
// This should fail because it's the only member
err := validateDeleteAccount(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "Cannot delete the only member")
})
t.Run("DeleteAccount_MultipleMembersSuccess", func(t *testing.T) {
orgID := primitive.NewObjectID()
accountID := primitive.NewObjectID()
otherAccountID := primitive.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Multi Member Org"},
},
Members: []primitive.ObjectID{accountID, otherAccountID}, // Multiple members
}
org.ID = orgID
// This should succeed because there are multiple members
err := validateDeleteAccount(org)
require.NoError(t, err)
})
t.Run("DeleteAccount_EmptyMembersList", func(t *testing.T) {
orgID := primitive.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Empty Org"},
},
Members: []primitive.ObjectID{}, // No members
}
org.ID = orgID
// This should fail because there are no members
err := validateDeleteAccount(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "Cannot delete the only member")
})
}
func TestDeleteOrganization_Validation(t *testing.T) {
t.Run("DeleteOrganization_NilOrganization", func(t *testing.T) {
err := validateDeleteOrganization(nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization cannot be nil")
})
t.Run("DeleteOrganization_EmptyOrganization", func(t *testing.T) {
org := &model.Organization{}
err := validateDeleteOrganization(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization ID cannot be empty")
})
t.Run("DeleteOrganization_ValidOrganization", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = primitive.NewObjectID()
err := validateDeleteOrganization(org)
require.NoError(t, err)
})
}
func TestDeleteAll_Validation(t *testing.T) {
t.Run("DeleteAll_NilOrganization", func(t *testing.T) {
accountID := primitive.NewObjectID()
err := validateDeleteAll(nil, accountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization cannot be nil")
})
t.Run("DeleteAll_EmptyAccountID", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = primitive.NewObjectID()
err := validateDeleteAll(org, primitive.NilObjectID)
require.Error(t, err)
assert.Contains(t, err.Error(), "account ID cannot be empty")
})
t.Run("DeleteAll_ValidInput", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = primitive.NewObjectID()
accountID := primitive.NewObjectID()
err := validateDeleteAll(org, accountID)
require.NoError(t, err)
})
}
// Helper functions that implement the validation logic from the service
func validateDeleteAccount(org *model.Organization) error {
if len(org.Members) <= 1 {
return merrors.InvalidArgument("Cannot delete the only member of an organization")
}
return nil
}
func validateDeleteOrganization(org *model.Organization) error {
if org == nil {
return merrors.InvalidArgument("organization cannot be nil")
}
if org.ID == primitive.NilObjectID {
return merrors.InvalidArgument("organization ID cannot be empty")
}
return nil
}
func validateDeleteAll(org *model.Organization, accountRef primitive.ObjectID) error {
if org == nil {
return merrors.InvalidArgument("organization cannot be nil")
}
if accountRef == primitive.NilObjectID {
return merrors.InvalidArgument("account ID cannot be empty")
}
return nil
}

View File

@@ -0,0 +1,298 @@
package accountserviceimp
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/model"
apiconfig "github.com/tech/sendico/server/internal/api/config"
"go.uber.org/zap"
)
// TestValidatePassword tests the password validation logic directly
func TestValidatePassword(t *testing.T) {
config := &apiconfig.PasswordConfig{
Check: apiconfig.PasswordChecks{
MinLength: 8,
Digit: true,
Upper: true,
Lower: true,
Special: true,
},
TokenLength: 32,
}
// Create a minimal service for testing password validation
logger := zap.NewNop() // Use no-op logger for tests
service := &service{
config: config,
logger: logger,
}
t.Run("ValidPassword", func(t *testing.T) {
err := service.ValidatePassword("TestPassword123!", nil)
assert.NoError(t, err)
})
t.Run("PasswordTooShort", func(t *testing.T) {
err := service.ValidatePassword("Test1!", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "at least 8 characters")
})
t.Run("PasswordMissingDigit", func(t *testing.T) {
err := service.ValidatePassword("TestPassword!", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "at least one digit")
})
t.Run("PasswordMissingUppercase", func(t *testing.T) {
err := service.ValidatePassword("testpassword123!", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "at least one uppercase")
})
t.Run("PasswordMissingLowercase", func(t *testing.T) {
err := service.ValidatePassword("TESTPASSWORD123!", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "at least one lowercase")
})
t.Run("PasswordMissingSpecialCharacter", func(t *testing.T) {
err := service.ValidatePassword("TestPassword123", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "at least one special character")
})
t.Run("PasswordSameAsOld", func(t *testing.T) {
oldPassword := "TestPassword123!"
err := service.ValidatePassword("TestPassword123!", &oldPassword)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot be the same as the old password")
})
}
// TestValidateAccount tests the account validation logic directly
func TestValidateAccount(t *testing.T) {
config := &apiconfig.PasswordConfig{
Check: apiconfig.PasswordChecks{
MinLength: 8,
Digit: true,
Upper: true,
Lower: true,
Special: true,
},
TokenLength: 32,
}
logger := zap.NewNop() // Use no-op logger for tests
service := &service{
config: config,
logger: logger,
}
t.Run("ValidAccount", func(t *testing.T) {
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
},
Password: "TestPassword123!",
}
originalPassword := account.Password
err := service.ValidateAccount(account)
require.NoError(t, err)
// Password should be hashed after validation
assert.NotEqual(t, originalPassword, account.Password)
assert.NotEmpty(t, account.VerifyToken)
assert.Equal(t, config.TokenLength, len(account.VerifyToken))
})
t.Run("AccountMissingName", func(t *testing.T) {
account := &model.Account{
AccountPublic: model.AccountPublic{
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
},
Password: "TestPassword123!",
}
err := service.ValidateAccount(account)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Name must not be empty")
})
t.Run("AccountMissingLogin", func(t *testing.T) {
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: model.Describable{
Name: "Test User",
},
},
},
Password: "TestPassword123!",
}
err := service.ValidateAccount(account)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Login must not be empty")
})
t.Run("AccountMissingPassword", func(t *testing.T) {
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
},
Password: "",
}
err := service.ValidateAccount(account)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Password must not be empty")
})
t.Run("AccountInvalidPassword", func(t *testing.T) {
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
},
Password: "weak", // Should fail validation
}
err := service.ValidateAccount(account)
assert.Error(t, err)
// Should fail on password validation
assert.Contains(t, err.Error(), "at least 8 characters")
})
}
// TestPasswordConfiguration verifies different password rule configurations
func TestPasswordConfiguration(t *testing.T) {
t.Run("MinimalRequirements", func(t *testing.T) {
config := &apiconfig.PasswordConfig{
Check: apiconfig.PasswordChecks{
MinLength: 4,
Digit: false,
Upper: false,
Lower: false,
Special: false,
},
TokenLength: 16,
}
logger := zap.NewNop() // Use no-op logger for tests
service := &service{
config: config,
logger: logger,
}
// Should pass with minimal requirements
err := service.ValidatePassword("test", nil)
assert.NoError(t, err)
})
t.Run("StrictRequirements", func(t *testing.T) {
config := &apiconfig.PasswordConfig{
Check: apiconfig.PasswordChecks{
MinLength: 12,
Digit: true,
Upper: true,
Lower: true,
Special: true,
},
TokenLength: 64,
}
logger := zap.NewNop() // Use no-op logger for tests
service := &service{
config: config,
logger: logger,
}
// Should fail with shorter password
err := service.ValidatePassword("Test123!", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "at least 12 characters")
// Should pass with longer password
err = service.ValidatePassword("TestPassword123!", nil)
assert.NoError(t, err)
})
}
// TestTokenGeneration verifies that verification tokens are generated with correct length
func TestTokenGeneration(t *testing.T) {
testCases := []struct {
name string
tokenLength int
}{
{"Short", 8},
{"Medium", 32},
{"Long", 64},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config := &apiconfig.PasswordConfig{
Check: apiconfig.PasswordChecks{
MinLength: 8,
Digit: true,
Upper: true,
Lower: true,
Special: true,
},
TokenLength: tc.tokenLength,
}
logger := zap.NewNop() // Use no-op logger for tests
service := &service{
config: config,
logger: logger,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
},
Password: "TestPassword123!",
}
err := service.ValidateAccount(account)
require.NoError(t, err)
assert.Equal(t, tc.tokenLength, len(account.VerifyToken))
})
}
}

View File

@@ -0,0 +1,99 @@
package accountservice
import (
"context"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/auth/management"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
accountserviceimp "github.com/tech/sendico/server/interface/accountservice/internal"
"github.com/tech/sendico/server/interface/middleware"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// AccountService defines all account-related workflows.
type AccountService interface {
// ValidateAccount will:
// 1) check it's completeness
// 2) hash password
// 3) prepare verification token
ValidateAccount(
acct *model.Account,
) error
// ValidatePassword will:
// 1) check passsword conformance
ValidatePassword(
password string,
oldPassword *string,
) error
// ResetPassword will:
// 1) generate reset password token
ResetPassword(
ctx context.Context,
acct *model.Account,
) error
// CreateAccount will:
// 1) create the account
// 2) add it to the orgs member list
// 3) assign the given role description to it
CreateAccount(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID primitive.ObjectID,
) error
JoinOrganization(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID primitive.ObjectID,
) error
UpdateLogin(
ctx context.Context,
acct *model.Account,
newLogin string,
) error
// DeleteAccount deletes the account and removes it from the org.
DeleteAccount(
ctx context.Context,
org *model.Organization,
accountRef primitive.ObjectID,
) error
// RemoveAccountFromOrganization just drops it from the member slice.
RemoveAccountFromOrganization(
ctx context.Context,
org *model.Organization,
accountRef primitive.ObjectID,
) error
DeleteOrganization(
ctx context.Context,
org *model.Organization,
) error
// DeleteAll deletes both the organization and the account.
DeleteAll(
ctx context.Context,
org *model.Organization,
accountRef primitive.ObjectID,
) error
}
func NewAccountService(
logger mlogger.Logger,
dbf db.Factory,
enforcer auth.Enforcer,
roleManeger management.Role,
config *middleware.PasswordConfig,
) (AccountService, error) {
return accountserviceimp.NewAccountService(logger, dbf, enforcer, roleManeger, config)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
)
type API interface {
Logger() mlogger.Logger
DomainProvider() domainprovider.DomainProvider
Config() *Config
DBFactory() db.Factory
Permissions() auth.Provider
Register() Register
}
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)

View File

@@ -0,0 +1,11 @@
package api
import (
mwa "github.com/tech/sendico/server/interface/middleware"
fsc "github.com/tech/sendico/server/interface/services/fileservice/config"
)
type Config struct {
Mw *mwa.Config `yaml:"middleware"`
Storage *fsc.Config `yaml:"storage"`
}

View File

@@ -0,0 +1,10 @@
package permissions
import (
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/model"
)
func Deny(_ *model.Account, _ *auth.Enforcer) (bool, error) {
return true, nil
}

View File

@@ -0,0 +1,10 @@
package permissions
import (
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/model"
)
func DoNotCheck(_ *model.Account, _ *auth.Enforcer) (bool, error) {
return true, nil
}

View File

@@ -0,0 +1,17 @@
package api
import (
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/api/ws"
)
type Register interface {
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
WSHandler(messageType string, handler ws.HandlerFunc)
Messaging() messaging.Register
}

View File

@@ -0,0 +1,7 @@
package srequest
import "github.com/tech/sendico/pkg/model"
type AcceptInvitation struct {
Account *model.AccountData `json:"account,omitempty"`
}

View File

@@ -0,0 +1,12 @@
package srequest
import (
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type ChangePolicies struct {
RoleRef primitive.ObjectID `json:"roleRef"`
Add *[]model.RolePolicy `json:"add,omitempty"`
Remove *[]model.RolePolicy `json:"remove,omitempty"`
}

View File

@@ -0,0 +1,8 @@
package srequest
import "go.mongodb.org/mongo-driver/bson/primitive"
type ChangeRole struct {
AccountRef primitive.ObjectID `json:"accountRef"`
NewRoleDescriptionRef primitive.ObjectID `json:"newRoleDescriptionRef"`
}

View File

@@ -0,0 +1,7 @@
package srequest
import "go.mongodb.org/mongo-driver/bson/primitive"
type FileUpload struct {
ObjRef primitive.ObjectID `json:"objRef"`
}

View File

@@ -0,0 +1,7 @@
package srequest
import (
"github.com/tech/sendico/pkg/model"
)
type CreateInvitation = model.Invitation

View File

@@ -0,0 +1,8 @@
package srequest
import "github.com/tech/sendico/pkg/model"
type Login struct {
model.SessionIdentifier `json:",inline"`
model.LoginData `json:"login"`
}

View File

@@ -0,0 +1,15 @@
package srequest
type ChangePassword struct {
Old string `json:"old"`
New string `json:"new"`
DeviceID string `json:"deviceId"`
}
type ResetPassword struct {
Password string `json:"password"`
}
type ForgotPassword struct {
Login string `json:"login"`
}

View File

@@ -0,0 +1,8 @@
package srequest
import "github.com/tech/sendico/pkg/model"
type CreatePriorityGroup struct {
Description model.Describable `json:"description"`
Priorities []model.Colorable `json:"priorities"`
}

View File

@@ -0,0 +1,31 @@
package srequest
import (
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type CreateProject struct {
Project model.Describable `json:"project"`
LogoURI *string `json:"logoUrl,omitempty"`
PrioriyGroupRef primitive.ObjectID `json:"priorityGroupRef"`
StatusGroupRef primitive.ObjectID `json:"statusGroupRef"`
Mnemonic string `json:"mnemonic"`
}
type ProjectPreview struct {
Projects []primitive.ObjectID `json:"projects"`
}
type TagFilterMode string
const (
TagFilterModeNone TagFilterMode = "none"
TagFilterModePresent TagFilterMode = "present"
TagFilterModeMissing TagFilterMode = "missing"
TagFilterModeIncludeAny TagFilterMode = "includeAny"
TagFilterModeIncludeAll TagFilterMode = "includeAll"
TagFilterModeExcludeAny TagFilterMode = "excludeAny"
)
type ProjectsFilter = model.ProjectFilterBase

View File

@@ -0,0 +1,11 @@
package srequest
import (
"go.mongodb.org/mongo-driver/bson/primitive"
)
// DeleteProject represents a request to delete a project
type DeleteProject struct {
OrganizationRef primitive.ObjectID `json:"organizationRef"` // If provided, move tasks to this project. If null, delete all tasks
MoveTasksToProjectRef *primitive.ObjectID `json:"moveTasksToProjectRef,omitempty"` // If provided, move tasks to this project. If null, delete all tasks
}

View File

@@ -0,0 +1,5 @@
package srequest
import "github.com/tech/sendico/pkg/model"
type AccessTokenRefresh = model.ClientRefreshToken

View File

@@ -0,0 +1,19 @@
package srequest
import "go.mongodb.org/mongo-driver/bson/primitive"
type Reorder struct {
ParentRef primitive.ObjectID `json:"parentRef"`
From int `json:"from"`
To int `json:"to"`
}
type ReorderX struct {
ObjectRef primitive.ObjectID `json:"objectRef"`
To int `json:"to"`
}
type ReorderXDefault struct {
ReorderX `json:",inline"`
ParentRef primitive.ObjectID `json:"parentRef"`
}

View File

@@ -0,0 +1,5 @@
package srequest
import "github.com/tech/sendico/pkg/model"
type TokenRefreshRotate = model.ClientRefreshToken

View File

@@ -0,0 +1,13 @@
package srequest
import "go.mongodb.org/mongo-driver/bson/primitive"
type GroupItemChange struct {
GroupRef primitive.ObjectID `json:"groupRef"`
ItemRef primitive.ObjectID `json:"itemRef"`
}
type RemoveItemFromGroup struct {
GroupItemChange `json:",inline"`
TargetItemRef primitive.ObjectID `json:"targetItemRef"`
}

View File

@@ -0,0 +1,14 @@
package srequest
import "github.com/tech/sendico/pkg/model"
type Signup struct {
Account model.AccountData `json:"account"`
OrganizationName string `json:"organizationName"`
OrganizationTimeZone string `json:"organizationTimeZone"`
DefaultPriorityGroup CreatePriorityGroup `json:"defaultPriorityGroup"`
DefaultStatusGroup CreateStatusGroup `json:"defaultStatusGroup"`
AnonymousUser model.Describable `json:"anonymousUser"`
OwnerRole model.Describable `json:"ownerRole"`
AnonymousRole model.Describable `json:"anonymousRole"`
}

View File

@@ -0,0 +1,312 @@
package srequest_test
import (
"encoding/json"
"fmt"
"testing"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Helper function to create string pointers
func stringPtr(s string) *string {
return &s
}
func TestSignupRequest_JSONSerialization(t *testing.T) {
signup := srequest.Signup{
Account: model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
Password: "TestPassword123!",
},
Name: "Test User",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},
OwnerRole: model.Describable{
Name: "Owner",
},
AnonymousRole: model.Describable{
Name: "Anonymous",
},
}
// Test JSON marshaling
jsonData, err := json.Marshal(signup)
require.NoError(t, err)
assert.NotEmpty(t, jsonData)
// Test JSON unmarshaling
var unmarshaled srequest.Signup
err = json.Unmarshal(jsonData, &unmarshaled)
require.NoError(t, err)
// Verify all fields are properly serialized/deserialized
assert.Equal(t, signup.Account.Name, unmarshaled.Account.Name)
assert.Equal(t, signup.Account.Login, unmarshaled.Account.Login)
assert.Equal(t, signup.Account.Password, unmarshaled.Account.Password)
assert.Equal(t, signup.OrganizationName, unmarshaled.OrganizationName)
assert.Equal(t, signup.OrganizationTimeZone, unmarshaled.OrganizationTimeZone)
assert.Equal(t, signup.DefaultPriorityGroup.Description.Name, unmarshaled.DefaultPriorityGroup.Description.Name)
assert.Equal(t, len(signup.DefaultPriorityGroup.Priorities), len(unmarshaled.DefaultPriorityGroup.Priorities))
assert.Equal(t, signup.AnonymousUser.Name, unmarshaled.AnonymousUser.Name)
assert.Equal(t, signup.OwnerRole.Name, unmarshaled.OwnerRole.Name)
assert.Equal(t, signup.AnonymousRole.Name, unmarshaled.AnonymousRole.Name)
// Verify priorities
for i, priority := range signup.DefaultPriorityGroup.Priorities {
assert.Equal(t, priority.Name, unmarshaled.DefaultPriorityGroup.Priorities[i].Name)
if priority.Color != nil && unmarshaled.DefaultPriorityGroup.Priorities[i].Color != nil {
assert.Equal(t, *priority.Color, *unmarshaled.DefaultPriorityGroup.Priorities[i].Color)
}
}
}
func TestSignupRequest_MinimalValidRequest(t *testing.T) {
signup := srequest.Signup{
Account: model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
Password: "TestPassword123!",
},
Name: "Test User",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "Normal"},
Color: stringPtr("#000000"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous",
},
OwnerRole: model.Describable{
Name: "Owner",
},
AnonymousRole: model.Describable{
Name: "Anonymous",
},
}
// Test JSON marshaling
jsonData, err := json.Marshal(signup)
require.NoError(t, err)
assert.NotEmpty(t, jsonData)
// Test JSON unmarshaling
var unmarshaled srequest.Signup
err = json.Unmarshal(jsonData, &unmarshaled)
require.NoError(t, err)
// Verify minimal request is valid
assert.Equal(t, signup.Account.Name, unmarshaled.Account.Name)
assert.Equal(t, signup.Account.Login, unmarshaled.Account.Login)
assert.Equal(t, signup.OrganizationName, unmarshaled.OrganizationName)
assert.Len(t, unmarshaled.DefaultPriorityGroup.Priorities, 1)
}
func TestSignupRequest_InvalidJSON(t *testing.T) {
invalidJSONs := []string{
`{"account": invalid}`,
`{"organizationName": 123}`,
`{"organizationTimeZone": true}`,
`{"defaultPriorityGroup": "not_an_object"}`,
`{"anonymousUser": []}`,
`{"anonymousRole": 456}`,
`{invalid json}`,
}
for i, invalidJSON := range invalidJSONs {
t.Run(fmt.Sprintf("Invalid JSON %d", i), func(t *testing.T) {
var signup srequest.Signup
err := json.Unmarshal([]byte(invalidJSON), &signup)
require.Error(t, err)
})
}
}
func TestSignupRequest_UnicodeCharacters(t *testing.T) {
signup := srequest.Signup{
Account: model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "测试@example.com",
},
Password: "TestPassword123!",
},
Name: "Test 用户 Üser",
},
OrganizationName: "测试 Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "默认 Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "高"},
Color: stringPtr("#FF0000"),
},
},
},
AnonymousUser: model.Describable{
Name: "匿名 User",
},
OwnerRole: model.Describable{
Name: "所有者",
},
AnonymousRole: model.Describable{
Name: "匿名",
},
}
// Test JSON marshaling
jsonData, err := json.Marshal(signup)
require.NoError(t, err)
assert.NotEmpty(t, jsonData)
// Test JSON unmarshaling
var unmarshaled srequest.Signup
err = json.Unmarshal(jsonData, &unmarshaled)
require.NoError(t, err)
// Verify unicode characters are properly handled
assert.Equal(t, "测试@example.com", unmarshaled.Account.Login)
assert.Equal(t, "Test 用户 Üser", unmarshaled.Account.Name)
assert.Equal(t, "测试 Organization", unmarshaled.OrganizationName)
assert.Equal(t, "默认 Priority Group", unmarshaled.DefaultPriorityGroup.Description.Name)
assert.Equal(t, "高", unmarshaled.DefaultPriorityGroup.Priorities[0].Name)
assert.Equal(t, "匿名 User", unmarshaled.AnonymousUser.Name)
assert.Equal(t, "所有者", unmarshaled.OwnerRole.Name)
assert.Equal(t, "匿名", unmarshaled.AnonymousRole.Name)
}
func TestCreatePriorityGroup_JSONSerialization(t *testing.T) {
priorityGroup := srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Test Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "Critical"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF8000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
}
// Test JSON marshaling
jsonData, err := json.Marshal(priorityGroup)
require.NoError(t, err)
assert.NotEmpty(t, jsonData)
// Test JSON unmarshaling
var unmarshaled srequest.CreatePriorityGroup
err = json.Unmarshal(jsonData, &unmarshaled)
require.NoError(t, err)
// Verify all fields are properly serialized/deserialized
assert.Equal(t, priorityGroup.Description.Name, unmarshaled.Description.Name)
assert.Equal(t, len(priorityGroup.Priorities), len(unmarshaled.Priorities))
for i, priority := range priorityGroup.Priorities {
assert.Equal(t, priority.Name, unmarshaled.Priorities[i].Name)
if priority.Color != nil && unmarshaled.Priorities[i].Color != nil {
assert.Equal(t, *priority.Color, *unmarshaled.Priorities[i].Color)
}
}
}
func TestCreatePriorityGroup_EmptyPriorities(t *testing.T) {
priorityGroup := srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Empty Priority Group",
},
Priorities: []model.Colorable{},
}
// Test JSON marshaling
jsonData, err := json.Marshal(priorityGroup)
require.NoError(t, err)
assert.NotEmpty(t, jsonData)
// Test JSON unmarshaling
var unmarshaled srequest.CreatePriorityGroup
err = json.Unmarshal(jsonData, &unmarshaled)
require.NoError(t, err)
// Verify empty priorities array is handled correctly
assert.Equal(t, priorityGroup.Description.Name, unmarshaled.Description.Name)
assert.Empty(t, unmarshaled.Priorities)
}
func TestCreatePriorityGroup_NilPriorities(t *testing.T) {
priorityGroup := srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Nil Priority Group",
},
Priorities: nil,
}
// Test JSON marshaling
jsonData, err := json.Marshal(priorityGroup)
require.NoError(t, err)
assert.NotEmpty(t, jsonData)
// Test JSON unmarshaling
var unmarshaled srequest.CreatePriorityGroup
err = json.Unmarshal(jsonData, &unmarshaled)
require.NoError(t, err)
// Verify nil priorities is handled correctly
assert.Equal(t, priorityGroup.Description.Name, unmarshaled.Description.Name)
assert.Nil(t, unmarshaled.Priorities)
}

View File

@@ -0,0 +1,16 @@
package srequest
import (
"github.com/tech/sendico/pkg/model"
)
type CreateStatus struct {
model.Colorable `json:"description"`
Icon string `json:"icon"`
IsFinal bool `json:"isFinal"`
}
type CreateStatusGroup struct {
Description model.Describable `json:"description"`
Statuses []CreateStatus `json:"statuses"`
}

View File

@@ -0,0 +1,20 @@
package srequest
import "go.mongodb.org/mongo-driver/bson/primitive"
// TaggableSingle is used for single tag operations (add/remove tag)
type TaggableSingle struct {
ObjectRef primitive.ObjectID `json:"objectRef"`
TagRef primitive.ObjectID `json:"tagRef"`
}
// TaggableMultiple is used for multiple tag operations (add tags, set tags)
type TaggableMultiple struct {
ObjectRef primitive.ObjectID `json:"objectRef"`
TagRefs []primitive.ObjectID `json:"tagRefs"`
}
// TaggableObject is used for object-only operations (remove all tags, get tags)
type TaggableObject struct {
ObjectRef primitive.ObjectID `json:"objectRef"`
}

View File

@@ -0,0 +1,62 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type accountData struct {
model.AccountPublic `json:",inline"`
IsAnonymous bool `json:"isAnonymous"`
}
type accountResponse struct {
authResponse `json:",inline"`
Account accountData `json:"account"`
}
func _createAccount(account *model.Account, isAnonymous bool) *accountData {
return &accountData{
AccountPublic: account.AccountPublic,
IsAnonymous: isAnonymous,
}
}
func _toAccount(account *model.Account, orgRef primitive.ObjectID) *accountData {
return _createAccount(account, model.AccountIsAnonymous(&account.UserDataBase, orgRef))
}
func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenData) http.HandlerFunc {
return response.Ok(
logger,
&accountResponse{
Account: *_createAccount(account, false),
authResponse: authResponse{AccessToken: *accessToken},
},
)
}
type accountsResponse struct {
authResponse `json:",inline"`
Accounts []accountData `json:"accounts"`
}
func Accounts(logger mlogger.Logger, accounts []model.Account, orgRef primitive.ObjectID, accessToken *TokenData) http.HandlerFunc {
// Convert each account to its public representation.
publicAccounts := make([]accountData, len(accounts))
for i, a := range accounts {
publicAccounts[i] = *_toAccount(&a, orgRef)
}
return response.Ok(
logger,
&accountsResponse{
Accounts: publicAccounts,
authResponse: authResponse{AccessToken: *accessToken},
},
)
}

View File

@@ -0,0 +1,5 @@
package sresponse
type authResponse struct {
AccessToken TokenData `json:"accessToken"`
}

View File

@@ -0,0 +1,15 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
func BadRPassword(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
logger.Info("Failed password validation check", zap.Error(err))
return response.BadRequest(logger, source, "invalid_request", err.Error())
}

View File

@@ -0,0 +1,24 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type commentPreviewResponse struct {
authResponse `json:",inline"`
Comments []model.CommentPreview `json:"comments"`
}
func CommentPreview(logger mlogger.Logger, accessToken *TokenData, comments []model.CommentPreview) http.HandlerFunc {
return response.Ok(
logger,
&commentPreviewResponse{
Comments: comments,
authResponse: authResponse{AccessToken: *accessToken},
},
)
}

View File

@@ -0,0 +1,24 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type dzoneResponse struct {
authResponse `json:",inline"`
DZone model.DZone `json:"dzone"`
}
func DZone(logger mlogger.Logger, dzone *model.DZone, accessToken *TokenData) http.HandlerFunc {
return response.Ok(
logger,
&dzoneResponse{
DZone: *dzone,
authResponse: authResponse{AccessToken: *accessToken},
},
)
}

View File

@@ -0,0 +1,16 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
)
type fileUpladed struct {
URL string `json:"url"`
}
func FileUploaded(logger mlogger.Logger, url string) http.HandlerFunc {
return response.Ok(logger, &fileUpladed{URL: url})
}

View File

@@ -0,0 +1,21 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type invitationResp struct {
Invitation model.PublicInvitation `json:"invitation"`
}
func Invitation(logger mlogger.Logger, invitation *model.PublicInvitation) http.HandlerFunc {
return response.Ok(logger, &invitationResp{Invitation: *invitation})
}
func Invitations(logger mlogger.Logger, invitations []model.Invitation) http.HandlerFunc {
return response.Ok(logger, invitations)
}

View File

@@ -0,0 +1,27 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type loginResponse struct {
accountResponse
RefreshToken TokenData `json:"refreshToken"`
}
func Login(logger mlogger.Logger, account *model.Account, accessToken, refreshToken *TokenData) http.HandlerFunc {
return response.Ok(
logger,
&loginResponse{
accountResponse: accountResponse{
Account: *_createAccount(account, false),
authResponse: authResponse{AccessToken: *accessToken},
},
RefreshToken: *refreshToken,
},
)
}

View File

@@ -0,0 +1,49 @@
package sresponse
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
)
type DynamicResponse[T any] struct {
authResponse `json:",inline"`
Items []T
// FieldName is the JSON key to use for the items.
FieldName string
}
func (dr DynamicResponse[T]) MarshalJSON() ([]byte, error) {
// Create a temporary map to hold the keys and values.
m := map[string]any{
dr.FieldName: dr.Items,
"accessToken": dr.AccessToken,
}
return json.Marshal(m)
}
type handler = func(logger mlogger.Logger, data any) http.HandlerFunc
func objectsAuth[T any](logger mlogger.Logger, items []T, accessToken *TokenData, resource mservice.Type, handler handler) http.HandlerFunc {
resp := &DynamicResponse[T]{
Items: items,
authResponse: authResponse{AccessToken: *accessToken},
FieldName: resource,
}
return handler(logger, resp)
}
func ObjectsAuth[T any](logger mlogger.Logger, items []T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc {
return objectsAuth(logger, items, accessToken, resource, response.Ok)
}
func ObjectAuth[T any](logger mlogger.Logger, item *T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc {
return ObjectsAuth(logger, []T{*item}, accessToken, resource)
}
func ObjectAuthCreated[T any](logger mlogger.Logger, item *T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc {
return objectsAuth(logger, []T{*item}, accessToken, resource, response.Created)
}

View File

@@ -0,0 +1,35 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type organizationsResponse struct {
authResponse `json:",inline"`
Organizations []model.Organization `json:"organizations"`
}
func Organization(logger mlogger.Logger, organization *model.Organization, accessToken *TokenData) http.HandlerFunc {
return Organizations(logger, []model.Organization{*organization}, accessToken)
}
func Organizations(logger mlogger.Logger, organizations []model.Organization, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, organizationsResponse{
Organizations: organizations,
authResponse: authResponse{AccessToken: *accessToken},
})
}
type organizationPublicResponse struct {
Organizations []model.OrganizationBase `json:"organizations"`
}
func OrganizationPublic(logger mlogger.Logger, organization *model.OrganizationBase) http.HandlerFunc {
return response.Ok(logger, organizationPublicResponse{
[]model.OrganizationBase{*organization},
})
}

View File

@@ -0,0 +1,45 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type permissionsDescription struct {
Roles []model.RoleDescription `json:"roles"`
Policies []model.PolicyDescription `json:"policies"`
}
type permissionsData struct {
Roles []model.Role `json:"roles"`
Policies []model.RolePolicy `json:"policies"`
Permissions []model.Permission `json:"permissions"`
}
type permissionsResponse struct {
authResponse `json:",inline"`
Descriptions permissionsDescription `json:"descriptions"`
Permissions permissionsData `json:"permissions"`
}
func Permisssions(logger mlogger.Logger,
rolesDescs []model.RoleDescription, policiesDescs []model.PolicyDescription,
roles []model.Role, policies []model.RolePolicy, permissions []model.Permission,
accessToken *TokenData,
) http.HandlerFunc {
return response.Ok(logger, permissionsResponse{
Descriptions: permissionsDescription{
Roles: rolesDescs,
Policies: policiesDescs,
},
Permissions: permissionsData{
Roles: roles,
Policies: policies,
Permissions: permissions,
},
authResponse: authResponse{AccessToken: *accessToken},
})
}

View File

@@ -0,0 +1,37 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type projectsResponse struct {
authResponse `json:",inline"`
Projects []model.Project `json:"projects"`
}
func Projects(logger mlogger.Logger, projects []model.Project, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, projectsResponse{
Projects: projects,
authResponse: authResponse{AccessToken: *accessToken},
})
}
func Project(logger mlogger.Logger, project *model.Project, accessToken *TokenData) http.HandlerFunc {
return Projects(logger, []model.Project{*project}, accessToken)
}
type projectPreviewsResponse struct {
authResponse `json:",inline"`
Previews []model.ProjectPreview `json:"previews"`
}
func ProjectsPreviews(logger mlogger.Logger, previews []model.ProjectPreview, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, &projectPreviewsResponse{
authResponse: authResponse{AccessToken: *accessToken},
Previews: previews,
})
}

View File

@@ -0,0 +1,12 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/model"
)
type (
HandlerFunc = func(r *http.Request) http.HandlerFunc
AccountHandlerFunc = func(r *http.Request, account *model.Account, accessToken *TokenData) http.HandlerFunc
)

View File

@@ -0,0 +1,27 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
)
type resultAuth struct {
authResponse `json:",inline"`
response.Result `json:",inline"`
}
func Success(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, &resultAuth{
Result: response.Result{Result: true},
authResponse: authResponse{AccessToken: *accessToken},
})
}
func Failed(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc {
return response.Accepted(logger, &resultAuth{
Result: response.Result{Result: false},
authResponse: authResponse{AccessToken: *accessToken},
})
}

View File

@@ -0,0 +1,16 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
func SignUp(logger mlogger.Logger, account *model.Account) http.HandlerFunc {
return response.Ok(
logger,
&account.AccountBase,
)
}

View File

@@ -0,0 +1,25 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type statusesResponse struct {
authResponse `json:",inline"`
Statuses []model.Status `json:"statuses"`
}
func Statuses(logger mlogger.Logger, statuses []model.Status, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, statusesResponse{
Statuses: statuses,
authResponse: authResponse{AccessToken: *accessToken},
})
}
func Status(logger mlogger.Logger, status *model.Status, accessToken *TokenData) http.HandlerFunc {
return Statuses(logger, []model.Status{*status}, accessToken)
}

View File

@@ -0,0 +1,8 @@
package sresponse
import "time"
type TokenData struct {
Token string `json:"token"`
Expiration time.Time `json:"expiration"`
}

View File

@@ -0,0 +1,57 @@
package ws
import (
"net/http"
api "github.com/tech/sendico/pkg/api/http"
r "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/server/interface/api/ws"
"go.uber.org/zap"
"golang.org/x/net/websocket"
)
func respond(logger mlogger.Logger, conn *websocket.Conn, messageType, apiStatus, requestID string, data any) {
message := ws.Message{
BaseResponse: r.BaseResponse{
Status: apiStatus,
Data: data,
},
ID: requestID,
MessageType: messageType,
}
if err := websocket.JSON.Send(conn, message); err != nil {
logger.Warn("Failed to send error message", zap.Error(err), zap.Any("message", message))
}
}
func errorf(logger mlogger.Logger, messageType, requestID string, conn *websocket.Conn, resp r.ErrorResponse) {
logger.Debug(
"Writing error sresponse",
zap.String("error", resp.Error),
zap.String("details", resp.Details),
zap.Int("code", resp.Code),
)
respond(logger, conn, messageType, api.MSError, requestID, &resp)
}
func Ok(logger mlogger.Logger, requestID string, data any) ws.ResponseHandler {
res := func(messageType string, conn *websocket.Conn) {
logger.Debug("Successfully executed request", zap.Any("sresponse", data))
respond(logger, conn, messageType, api.MSSuccess, requestID, data)
}
return res
}
func Internal(logger mlogger.Logger, requestID string, err error) ws.ResponseHandler {
res := func(messageType string, conn *websocket.Conn) {
errorf(logger, messageType, requestID, conn,
r.ErrorResponse{
Error: "internal_error",
Details: err.Error(),
Code: http.StatusInternalServerError,
})
}
return res
}

View File

@@ -0,0 +1,9 @@
package ws
import (
ac "github.com/tech/sendico/server/internal/api/config"
)
type (
Config = ac.WebSocketConfig
)

View File

@@ -0,0 +1,12 @@
package ws
import (
"context"
"golang.org/x/net/websocket"
)
type (
ResponseHandler func(messageType string, conn *websocket.Conn)
HandlerFunc func(ctx context.Context, msg Message) ResponseHandler
)

View File

@@ -0,0 +1,9 @@
package ws
import "github.com/tech/sendico/pkg/api/http/response"
type Message struct {
response.BaseResponse
ID string `json:"id"`
MessageType string `json:"messageType"`
}

View File

@@ -0,0 +1,31 @@
package middleware
import (
"os"
ai "github.com/tech/sendico/server/internal/api/config"
)
type (
TokenConfig = ai.TokenConfig
Config = ai.Config
Signature = ai.SignatureConf
PasswordConfig = ai.PasswordConfig
)
type MapClaims = ai.MapClaims
func getKey(osEnv string) any {
if len(osEnv) == 0 {
return nil
}
return []byte(os.Getenv(osEnv))
}
func SignatureConf(conf *Config) Signature {
return Signature{
PrivateKey: []byte(os.Getenv(conf.Signature.PrivateKeyEnv)),
PublicKey: getKey(conf.Signature.PublicKeyEnv),
Algorithm: conf.Signature.Algorithm,
}
}

View File

@@ -0,0 +1,94 @@
package model
import (
"fmt"
"time"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
mduration "github.com/tech/sendico/pkg/mutil/duration"
"github.com/tech/sendico/server/interface/middleware"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type AccountToken struct {
AccountRef primitive.ObjectID
Login string
Name string
Locale string
Expiration time.Time
}
func createAccountToken(a *model.Account, expiration int) AccountToken {
return AccountToken{
AccountRef: *a.GetID(),
Login: a.Login,
Name: a.Name,
Locale: a.Locale,
Expiration: time.Now().Add(mduration.Param2Duration(expiration, time.Hour)),
}
}
func getTokenParam(claims middleware.MapClaims, param string) (string, error) {
id, ok := claims[param].(string)
if !ok {
return "", merrors.NoData(fmt.Sprintf("param '%s' not found", param))
}
return id, nil
}
const (
paramNameID = "id"
paramNameName = "name"
paramNameLocale = "locale"
paramNameLogin = "login"
paramNameExpiration = "exp"
)
func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) {
var at AccountToken
var err error
var account string
if account, err = getTokenParam(claims, paramNameID); err != nil {
return nil, err
}
if at.AccountRef, err = primitive.ObjectIDFromHex(account); err != nil {
return nil, err
}
if at.Login, err = getTokenParam(claims, paramNameLogin); err != nil {
return nil, err
}
if at.Name, err = getTokenParam(claims, paramNameName); err != nil {
return nil, err
}
if at.Locale, err = getTokenParam(claims, paramNameLocale); err != nil {
return nil, err
}
if expValue, ok := claims[paramNameExpiration]; ok {
switch exp := expValue.(type) {
case time.Time:
at.Expiration = exp
case float64:
at.Expiration = time.Unix(int64(exp), 0)
case int64:
at.Expiration = time.Unix(exp, 0)
default:
return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue))
}
} else {
return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue))
}
return &at, nil
}
func Account2Claims(a *model.Account, expiration int) middleware.MapClaims {
t := createAccountToken(a, expiration)
return middleware.MapClaims{
paramNameID: t.AccountRef.Hex(),
paramNameLogin: t.Login,
paramNameName: t.Name,
paramNameLocale: t.Locale,
paramNameExpiration: int64(t.Expiration.Unix()),
}
}

View File

@@ -0,0 +1,11 @@
package account
import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/accountapiimp"
)
func Create(a api.API) (mservice.MicroService, error) {
return accountapiimp.CreateAPI(a)
}

View File

@@ -0,0 +1,12 @@
package fileservice
import "github.com/tech/sendico/pkg/model"
type StorageType string
const (
LocalFS StorageType = "local_fs"
AwsS3 StorageType = "aws_s3"
)
type Config = model.DriverConfig[StorageType]

View File

@@ -0,0 +1,11 @@
package fileservice
import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/fileserviceimp"
)
func CreateAPI(a api.API, directory string) (mservice.MicroService, error) {
return fileserviceimp.CreateAPI(a, directory)
}

View File

@@ -0,0 +1,11 @@
package invitation
import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/invitationimp"
)
func Create(a api.API) (mservice.MicroService, error) {
return invitationimp.CreateAPI(a)
}

View File

@@ -0,0 +1,11 @@
package logo
import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/logoimp"
)
func Create(a api.API) (mservice.MicroService, error) {
return logoimp.CreateAPI(a)
}

View File

@@ -0,0 +1,11 @@
package organization
import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/organizationimp"
)
func Create(a api.API) (mservice.MicroService, error) {
return organizationimp.CreateAPI(a)
}

View File

@@ -0,0 +1,11 @@
package permission
import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/permissionsimp"
)
func Create(a api.API) (mservice.MicroService, error) {
return permissionsimp.CreateAPI(a)
}