diff --git a/core/contracts/auth.go b/core/contracts/auth.go new file mode 100644 index 00000000..12e10f35 --- /dev/null +++ b/core/contracts/auth.go @@ -0,0 +1,74 @@ +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" + "time" +) + +// UserDTO represents a unified user data transfer object across plugins. +type UserDTO struct { + ID uint64 `json:"id,string"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + IsActive bool `json:"is_active"` + IsAdmin bool `json:"is_admin"` + NeedChangePassword bool `json:"need_change_password,omitempty"` + Bio string `json:"bio,omitempty"` + Phone string `json:"phone,omitempty"` + Gender string `json:"gender,omitempty"` + Website string `json:"website,omitempty"` + Location string `json:"location,omitempty"` + LastLoginAt time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OAuthUserInfoDTO contains user identity claims obtained from an OAuth provider. +type OAuthUserInfoDTO struct { + ID uint64 `json:"id"` + Sub string `json:"sub"` + Username string `json:"username"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + Name string `json:"name"` + Active bool `json:"active"` + AvatarURL string `json:"avatar_url"` +} + +// OAuthProvider defines the pluggable OAuth provider contract. +type OAuthProvider interface { + Name() string + GetAuthURL(state string) string + ExchangeCode(ctx context.Context, code string) (*OAuthUserInfoDTO, error) +} + +// AuthService defines the contract for authentication, session verification, and token management. +type AuthService interface { + // RequireAuthMiddleware returns a middleware handler (compatible with gin.HandlerFunc or standard middleware). + RequireAuthMiddleware() any + + // RequireAdminMiddleware returns an admin authorization middleware. + RequireAdminMiddleware() any + + // GetCurrentUser retrieves the authenticated UserDTO from context. + GetCurrentUser(ctx context.Context) (*UserDTO, error) + + // VerifyToken validates an access token and returns the associated user DTO. + VerifyToken(ctx context.Context, token string) (*UserDTO, error) + + // CreateSession establishes an authenticated session for the given user ID. + CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error) + + // RevokeUserSessions revokes all active sessions and cached tokens for a user. + RevokeUserSessions(ctx context.Context, userID uint64) error +} + +// AuthRegistry allows downstream and domain plugins to register custom authentication providers. +type AuthRegistry interface { + RegisterOAuthProvider(name string, provider OAuthProvider) + GetOAuthProvider(name string) (OAuthProvider, bool) + ListOAuthProviders() []string +} diff --git a/core/contracts/user.go b/core/contracts/user.go new file mode 100644 index 00000000..db792364 --- /dev/null +++ b/core/contracts/user.go @@ -0,0 +1,63 @@ +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" +) + +// CreateUserRequest contains fields to register or create a new user. +type CreateUserRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Nickname string `json:"nickname"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` +} + +// UpdateUserProfileRequest contains fields for updating a user's profile. +type UpdateUserProfileRequest struct { + Nickname *string `json:"nickname,omitempty"` + Email *string `json:"email,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + Bio *string `json:"bio,omitempty"` + Phone *string `json:"phone,omitempty"` + Gender *string `json:"gender,omitempty"` + Website *string `json:"website,omitempty"` + Location *string `json:"location,omitempty"` +} + +// UserService defines the contract for user account management and profile queries. +type UserService interface { + // GetUserByID retrieves a user by ID. + GetUserByID(ctx context.Context, id uint64) (*UserDTO, error) + + // GetUserByUsername retrieves a user by username. + GetUserByUsername(ctx context.Context, username string) (*UserDTO, error) + + // GetUserByEmail retrieves a user by email. + GetUserByEmail(ctx context.Context, email string) (*UserDTO, error) + + // CreateUser registers or creates a new user account. + CreateUser(ctx context.Context, req CreateUserRequest) (*UserDTO, error) + + // UpdateProfile updates the profile of the specified user. + UpdateProfile(ctx context.Context, id uint64, req UpdateUserProfileRequest) (*UserDTO, error) + + // UpdatePassword updates the password for the specified user after verifying the old password. + UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error + + // VerifyPassword verifies if the given password matches the user's password. + VerifyPassword(ctx context.Context, id uint64, password string) bool + + // UpdateLastLogin updates the user's last login timestamp. + UpdateLastLogin(ctx context.Context, id uint64, ip string) error + + // ListUsers returns a paginated list of users with optional keyword search. + ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*UserDTO, int64, error) + + // SetUserActive sets the active/banned status for a user. + SetUserActive(ctx context.Context, id uint64, active bool) error + + // SetUserAdmin sets the admin role status for a user. + SetUserAdmin(ctx context.Context, id uint64, admin bool) error +} diff --git a/plugins/domain/admin/plugin.go b/plugins/domain/admin/plugin.go new file mode 100644 index 00000000..683a08e1 --- /dev/null +++ b/plugins/domain/admin/plugin.go @@ -0,0 +1,230 @@ +// Package admin provides the system management console, diagnostics, audit logging, and configuration hot-reloading domain plugin for Cordis. +package admin + +import ( + "context" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/extpoints" + "github.com/Rain-kl/Wavelet/internal/apps/admin" + admin_auth_source "github.com/Rain-kl/Wavelet/internal/apps/admin/auth_source" + admin_cache "github.com/Rain-kl/Wavelet/internal/apps/admin/cache" + admin_db_manage "github.com/Rain-kl/Wavelet/internal/apps/admin/db_manage" + admin_logs "github.com/Rain-kl/Wavelet/internal/apps/admin/logs" + admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push" + admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status" + "github.com/Rain-kl/Wavelet/internal/apps/admin/system_config" + admin_task "github.com/Rain-kl/Wavelet/internal/apps/admin/task" + admin_template "github.com/Rain-kl/Wavelet/internal/apps/admin/template" + admin_updater "github.com/Rain-kl/Wavelet/internal/apps/admin/updater" + admin_user "github.com/Rain-kl/Wavelet/internal/apps/admin/user" + "github.com/Rain-kl/Wavelet/internal/apps/oauth" + "github.com/Rain-kl/Wavelet/internal/apps/upload" + "github.com/hibiken/asynq" +) + +// Option configures the admin plugin. +type Option func(*Plugin) + +// Plugin implements core.Plugin to provide system administration and management APIs. +type Plugin struct{} + +// New creates a new admin domain plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier for the admin domain plugin. +func (p *Plugin) Name() string { + return "admin" +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "admin", + Version: "1.0.0", + Description: "System administration console, diagnostic monitoring, and configuration hot-reload plugin", + Author: "Wavelet Team", + } +} + +// Apply registers admin routes, tasks, schedules, and settings into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. Register Admin HTTP Routes + adminRouter := ctx.Router().Group("/api/v1/admin", oauth.LoginRequired(), admin.LoginAdminRequired()) + { + // Status & Diagnostics + adminRouter.GET("/status", admin_status.GetSystemStatus) + adminRouter.GET("/status/log-database", admin_status.GetLogDatabaseStatus) + adminRouter.GET("/db-info", admin_status.GetDatabaseInfo) + adminRouter.GET("/db-export", admin_status.ExportDatabase) + + // DB Management + dbGroup := adminRouter.Group("/db-manage") + { + dbGroup.GET("/overview", admin_db_manage.GetDBOverview) + dbGroup.GET("/tables", admin_db_manage.ListDBTables) + dbGroup.GET("/table-data", admin_db_manage.GetDBTableData) + dbGroup.POST("/query", admin_db_manage.ExecuteSQL) + } + + // Cache Management + cacheGroup := adminRouter.Group("/cache") + { + cacheGroup.GET("/status", admin_cache.GetCacheStatus) + cacheGroup.POST("/config", admin_cache.UpdateCacheConfig) + cacheGroup.POST("/clear", admin_cache.ClearCache) + } + + // Updater + updateGroup := adminRouter.Group("/update") + { + updateGroup.GET("", admin_updater.GetUpdateStatus) + updateGroup.POST("/apply", admin_updater.ApplyUpdate) + } + + // Logs + logsGroup := adminRouter.Group("/logs") + { + logsGroup.GET("", admin_logs.GetLogs) + logsGroup.GET("/access", admin_logs.GetAccessLogs) + logsGroup.GET("/analytics", admin_logs.GetLogsAnalytics) + logsGroup.GET("/ws", admin_logs.HandleLogWebSocket) + } + + // Users + usersGroup := adminRouter.Group("/users") + { + usersGroup.GET("", admin_user.ListUsers) + usersGroup.POST("", admin_user.CreateUser) + usersGroup.GET("/:id", admin_user.GetUser) + usersGroup.PUT("/:id/status", admin_user.UpdateUserStatus) + usersGroup.PUT("/:id", admin_user.UpdateUser) + usersGroup.DELETE("/:id", admin_user.DeleteUser) + } + + // Auth Sources + authSourcesGroup := adminRouter.Group("/auth-sources") + { + authSourcesGroup.GET("", admin_auth_source.ListAuthSources) + authSourcesGroup.POST("", admin_auth_source.CreateAuthSource) + authSourcesGroup.PUT("/:id", admin_auth_source.UpdateAuthSource) + authSourcesGroup.PUT("/:id/toggle", admin_auth_source.ToggleAuthSource) + authSourcesGroup.DELETE("/:id", admin_auth_source.DeleteAuthSource) + } + + // System Configs + configGroup := adminRouter.Group("/system-configs") + { + configGroup.GET("", system_config.ListSystemConfigs) + configGroup.POST("", system_config.CreateSystemConfig) + configGroup.POST("/smtp/test", system_config.TestSMTP) + + keyGroup := configGroup.Group("/:key") + { + keyGroup.GET("", system_config.GetSystemConfig) + keyGroup.PUT("", system_config.UpdateSystemConfig) + } + } + + // Templates + templateGroup := adminRouter.Group("/templates") + { + templateGroup.GET("", admin_template.ListTemplates) + templateGroup.POST("", admin_template.CreateTemplate) + + keyGroup := templateGroup.Group("/:key") + { + keyGroup.GET("", admin_template.GetTemplate) + keyGroup.PUT("", admin_template.UpdateTemplate) + keyGroup.DELETE("", admin_template.DeleteTemplate) + } + } + + // Uploads Management + uploadGroup := adminRouter.Group("/uploads") + { + uploadGroup.GET("", upload.ListFiles) + uploadGroup.GET("/stats", upload.GetFileStats) + uploadGroup.DELETE("/:id", upload.DeleteFile) + uploadGroup.GET("/download/:id", upload.DownloadFile) + uploadGroup.POST("/download/batch", upload.BatchDownloadFiles) + uploadGroup.GET("/types", upload.GetDistinctUploadTypes) + } + + // Tasks + taskGroup := adminRouter.Group("/tasks") + { + taskGroup.GET("/types", admin_task.ListTaskTypes) + taskGroup.POST("/dispatch", admin_task.DispatchTask) + + executions := taskGroup.Group("/executions") + { + executions.GET("", admin_task.ListTaskExecutions) + executions.GET("/:id", admin_task.GetTaskExecution) + executions.POST("/:id/retry", admin_task.RetryTask) + } + + schedules := taskGroup.Group("/schedules") + { + schedules.GET("", admin_task.ListSchedules) + schedules.POST("", admin_task.CreateSchedule) + schedules.PUT("/:id", admin_task.UpdateSchedule) + schedules.DELETE("/:id", admin_task.DeleteSchedule) + } + } + + // Push & Notifications + pushGroup := adminRouter.Group("/push") + { + events := pushGroup.Group("/events") + { + events.GET("", admin_push.ListEvents) + events.GET("/builtin", admin_push.ListBuiltInEvents) + events.POST("", admin_push.CreateEvent) + events.PUT("/:id", admin_push.UpdateEvent) + events.DELETE("/:id", admin_push.DeleteEvent) + events.POST("/:id/toggle", admin_push.ToggleEvent) + } + + pushGroup.GET("/histories", admin_push.ListHistories) + pushGroup.POST("/test", admin_push.TestPush) + + channels := pushGroup.Group("/channels") + { + channels.GET("/definitions", admin_push.ListChannelDefinitions) + channels.GET("", admin_push.ListChannels) + channels.POST("", admin_push.CreateChannel) + channels.PUT("/:id", admin_push.UpdateChannel) + channels.DELETE("/:id", admin_push.DeleteChannel) + channels.POST("/test", admin_push.TestChannel) + } + } + } + + // 2. Register Background Tasks + ctx.Task().Register("admin:system_cleanup", func(c context.Context, t *asynq.Task) error { + return nil + }, extpoints.WithTaskRetry(1)) + + // 3. Register Cron Schedules + ctx.Schedule().RegisterCron("0 4 * * *", "admin:system_cleanup", map[string]string{"type": "daily"}) + + // 4. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "admin.system_cleanup_cron", + Default: "0 4 * * *", + Description: "Cron expression for nightly system logs and expired tokens cleanup", + Type: "string", + Category: "maintenance", + }) + + return nil +} diff --git a/plugins/domain/admin/plugin_test.go b/plugins/domain/admin/plugin_test.go new file mode 100644 index 00000000..49d0cc51 --- /dev/null +++ b/plugins/domain/admin/plugin_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package admin_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/plugins/domain/admin" +) + +func TestAdminPluginUnit(t *testing.T) { + ctx := core.NewContext(context.Background()) + p := admin.New() + assert.Equal(t, "admin", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + // Verify routes + routes := ctx.Router().Routes() + assert.NotEmpty(t, routes) + + // Verify tasks + _, ok := ctx.Tasks().Get("admin:system_cleanup") + require.True(t, ok) + + // Verify schedules + sched, ok := ctx.Schedules().Get("admin:system_cleanup") + require.True(t, ok) + assert.Equal(t, "0 4 * * *", sched.Spec) + + // Verify settings + setting, ok := ctx.Settings().Get("admin.system_cleanup_cron") + require.True(t, ok) + assert.Equal(t, "0 4 * * *", setting.Default) +} diff --git a/plugins/domain/auth/migrations/20260827000001_create_auth_tables.sql b/plugins/domain/auth/migrations/20260827000001_create_auth_tables.sql new file mode 100644 index 00000000..f375c435 --- /dev/null +++ b/plugins/domain/auth/migrations/20260827000001_create_auth_tables.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_auth_sources ( + id BIGINT PRIMARY KEY, + name VARCHAR(80) NOT NULL UNIQUE, + type VARCHAR(20) NOT NULL, + display_name VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT FALSE, + client_id VARCHAR(255), + client_secret VARCHAR(1024), + openid_discovery_url VARCHAR(1024), + scopes VARCHAR(255), + icon_url VARCHAR(1024), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_auth_sources_is_active ON w_auth_sources (is_active); + +CREATE TABLE IF NOT EXISTS w_external_accounts ( + id BIGINT PRIMARY KEY, + auth_source_id BIGINT, + user_id BIGINT NOT NULL, + external_id VARCHAR(255) NOT NULL, + external_username VARCHAR(255), + email VARCHAR(255), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_external_accounts_auth_source_id ON w_external_accounts (auth_source_id); +CREATE INDEX IF NOT EXISTS idx_w_external_accounts_user_id ON w_external_accounts (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_w_external_accounts_source_external ON w_external_accounts (auth_source_id, external_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_external_accounts; +DROP TABLE IF EXISTS w_auth_sources; +-- +goose StatementEnd diff --git a/plugins/domain/auth/plugin.go b/plugins/domain/auth/plugin.go new file mode 100644 index 00000000..6f236416 --- /dev/null +++ b/plugins/domain/auth/plugin.go @@ -0,0 +1,112 @@ +// Package auth provides the authentication, OAuth, session management, and access token domain plugin for Cordis. +package auth + +import ( + "embed" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/core/extpoints" + "github.com/Rain-kl/Wavelet/internal/apps/oauth" +) + +//go:embed migrations/*.sql +var authMigrations embed.FS + +// Option configures the auth plugin. +type Option func(*Plugin) + +// WithAuthService sets a custom AuthService implementation. +func WithAuthService(svc contracts.AuthService) Option { + return func(p *Plugin) { + p.authSvc = svc + } +} + +// WithAuthRegistry sets a custom AuthRegistry implementation. +func WithAuthRegistry(reg contracts.AuthRegistry) Option { + return func(p *Plugin) { + p.authRegistry = reg + } +} + +// Plugin implements core.Plugin to provide authentication and OAuth domain services. +type Plugin struct { + authSvc contracts.AuthService + authRegistry contracts.AuthRegistry +} + +// New creates a new auth domain plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier for the auth domain plugin. +func (p *Plugin) Name() string { + return "auth" +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "auth", + Version: "1.0.0", + Description: "Authentication, OAuth, Session and Passkey domain plugin", + Author: "Wavelet Team", + } +} + +// Apply registers the auth migrations, services, routes, and settings into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. Register migrations + ctx.Migrations().Register("auth", authMigrations) + + // 2. Initialize and provide AuthService & AuthRegistry + if p.authSvc == nil { + p.authSvc = newAuthService() + } + if p.authRegistry == nil { + p.authRegistry = newAuthRegistry() + } + + core.Provide[contracts.AuthService](ctx, p.authSvc) + core.Provide[contracts.AuthRegistry](ctx, p.authRegistry) + + // 3. Register HTTP Routes + oauthGroup := ctx.Router().Group("/api/v1/oauth") + { + oauthGroup.GET("/sources", oauth.GetLoginSources) + oauthGroup.GET("/login", oauth.GetLoginURL) + oauthGroup.GET("/:source/authorize", oauth.Authorize) + oauthGroup.GET("/logout", oauth.Logout) + oauthGroup.POST("/callback", oauth.Callback) + oauthGroup.GET("/user-info", oauth.LoginRequired(), oauth.UserInfo) + oauthGroup.GET("/external-accounts", oauth.LoginRequired(), oauth.ListExternalAccounts) + oauthGroup.POST("/external-accounts/:id/delete", oauth.LoginRequired(), oauth.DeleteExternalAccount) + } + ctx.Router().GET("/api/v1/user-info", oauth.LoginRequired(), oauth.UserInfo) + + // 4. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "auth.session_age", + Default: 86400 * 7, + Description: "Default session lifetime in seconds", + Type: "integer", + Category: "security", + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "auth.login_rate_limit_max_attempts", + Default: 5, + Description: "Max login failure attempts before temporary IP lock", + Type: "integer", + Category: "security", + }) + + return nil +} diff --git a/plugins/domain/auth/plugin_test.go b/plugins/domain/auth/plugin_test.go new file mode 100644 index 00000000..827e07b3 --- /dev/null +++ b/plugins/domain/auth/plugin_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/contracts" + db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/plugins/domain/auth" +) + +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "auth_test.db") + testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + + require.NoError(t, testDB.AutoMigrate( + &model.User{}, + &model.AccessToken{}, + &model.AuthSource{}, + &model.ExternalAccount{}, + )) + + db.SetDB(testDB) + return testDB +} + +type mockProvider struct{} + +func (m *mockProvider) Name() string { return "custom" } +func (m *mockProvider) GetAuthURL(state string) string { + return "https://custom.com/auth?state=" + state +} +func (m *mockProvider) ExchangeCode(ctx context.Context, code string) (*contracts.OAuthUserInfoDTO, error) { + return &contracts.OAuthUserInfoDTO{ + ID: 555, + Username: "custom_user", + Email: "custom@example.com", + }, nil +} + +func TestAuthPluginUnit(t *testing.T) { + ctx := core.NewContext(context.Background()) + testDB := setupTestDB(t) + + p := auth.New() + assert.Equal(t, "auth", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + // Test AuthService injection + authSvc, err := core.Inject[contracts.AuthService](ctx) + require.NoError(t, err) + assert.NotNil(t, authSvc.RequireAuthMiddleware()) + assert.NotNil(t, authSvc.RequireAdminMiddleware()) + + // Test AuthRegistry injection + authReg, err := core.Inject[contracts.AuthRegistry](ctx) + require.NoError(t, err) + authReg.RegisterOAuthProvider("custom", &mockProvider{}) + prov, ok := authReg.GetOAuthProvider("custom") + require.True(t, ok) + assert.Equal(t, "custom", prov.Name()) + + // Test User Token Verification with dummy token + user := model.User{ + ID: 101, + Username: "token_user", + IsActive: true, + } + require.NoError(t, testDB.Create(&user).Error) + + tokenStr := "test-secret-token-123456" + tokenHash := model.HashToken(tokenStr) + tokenRecord := model.AccessToken{ + ID: 201, + UserID: user.ID, + TokenHash: tokenHash, + Name: "test-token", + IsAdmin: false, + } + require.NoError(t, testDB.Create(&tokenRecord).Error) + + userDTO, err := authSvc.VerifyToken(context.Background(), tokenStr) + require.NoError(t, err) + assert.Equal(t, user.ID, userDTO.ID) + assert.Equal(t, "token_user", userDTO.Username) + + // Empty token fails + _, err = authSvc.VerifyToken(context.Background(), "") + assert.Error(t, err) + + // Revoke sessions + require.NoError(t, authSvc.RevokeUserSessions(context.Background(), user.ID)) + + // GetCurrentUser from context + userCtx := context.WithValue(context.Background(), "user_obj", userDTO) + current, err := authSvc.GetCurrentUser(userCtx) + require.NoError(t, err) + assert.Equal(t, user.ID, current.ID) +} diff --git a/plugins/domain/auth/service.go b/plugins/domain/auth/service.go new file mode 100644 index 00000000..7a8f9c91 --- /dev/null +++ b/plugins/domain/auth/service.go @@ -0,0 +1,146 @@ +// Package auth provides authentication, OAuth, session management, and access token domain services. +package auth + +import ( + "context" + "errors" + "sync" + + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/internal/apps/admin" + "github.com/Rain-kl/Wavelet/internal/apps/oauth" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/gin-gonic/gin" +) + +func toUserDTO(u *model.User) *contracts.UserDTO { + if u == nil { + return nil + } + return &contracts.UserDTO{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Email: u.Email, + AvatarURL: u.AvatarURL, + IsActive: u.IsActive, + IsAdmin: u.IsAdmin, + Bio: u.Bio, + Phone: u.Phone, + Gender: u.Gender, + Website: u.Website, + Location: u.Location, + LastLoginAt: u.LastLoginAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +type authServiceImpl struct{} + +func newAuthService() contracts.AuthService { + return &authServiceImpl{} +} + +func (s *authServiceImpl) RequireAuthMiddleware() any { + return oauth.LoginRequired() +} + +func (s *authServiceImpl) RequireAdminMiddleware() any { + return admin.LoginAdminRequired() +} + +func (s *authServiceImpl) GetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) { + if ginCtx, ok := ctx.(*gin.Context); ok { + if u, ok := oauth.GetFromContext[*model.User](ginCtx, oauth.UserObjKey); ok && u != nil { + return toUserDTO(u), nil + } + } + + if v := ctx.Value(oauth.UserObjKey); v != nil { + if u, ok := v.(*model.User); ok && u != nil { + return toUserDTO(u), nil + } + if u, ok := v.(*contracts.UserDTO); ok && u != nil { + return u, nil + } + } + + return nil, errors.New("auth: user not found in context") +} + +func (s *authServiceImpl) VerifyToken(ctx context.Context, token string) (*contracts.UserDTO, error) { + if token == "" { + return nil, errors.New("auth: empty token") + } + + tokenHash := model.HashToken(token) + tokenRecord, err := oauth.GetCachedToken(ctx, tokenHash) + if err != nil { + dbToken, err := repository.GetAccessTokenByHash(ctx, tokenHash) + if err != nil { + return nil, err + } + tokenRecord = &dbToken + } + + user, err := oauth.GetCachedUser(ctx, tokenRecord.UserID) + if err != nil || !user.IsActive { + dbUser, err := repository.GetActiveUserByID(ctx, tokenRecord.UserID) + if err != nil { + return nil, err + } + user = &dbUser + } + + if user.Username == "system" { + return nil, errors.New("auth: system user token not allowed") + } + + return toUserDTO(user), nil +} + +func (s *authServiceImpl) CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error) { + // Session creation helper + return "", nil +} + +func (s *authServiceImpl) RevokeUserSessions(ctx context.Context, userID uint64) error { + oauth.InvalidateCachedUser(ctx, userID) + return nil +} + +type authRegistryImpl struct { + mu sync.RWMutex + providers map[string]contracts.OAuthProvider +} + +func newAuthRegistry() contracts.AuthRegistry { + return &authRegistryImpl{ + providers: make(map[string]contracts.OAuthProvider), + } +} + +func (r *authRegistryImpl) RegisterOAuthProvider(name string, provider contracts.OAuthProvider) { + r.mu.Lock() + defer r.mu.Unlock() + r.providers[name] = provider +} + +func (r *authRegistryImpl) GetOAuthProvider(name string) (contracts.OAuthProvider, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.providers[name] + return p, ok +} + +func (r *authRegistryImpl) ListOAuthProviders() []string { + r.mu.RLock() + defer r.mu.RUnlock() + res := make([]string, 0, len(r.providers)) + for name := range r.providers { + res = append(res, name) + } + return res +} diff --git a/plugins/domain/domain_test.go b/plugins/domain/domain_test.go new file mode 100644 index 00000000..9d466462 --- /dev/null +++ b/plugins/domain/domain_test.go @@ -0,0 +1,446 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package domain_test + +import ( + "context" + "io/fs" + "path/filepath" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/glebarez/sqlite" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/contracts" + db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/plugins/domain/admin" + "github.com/Rain-kl/Wavelet/plugins/domain/auth" + "github.com/Rain-kl/Wavelet/plugins/domain/message_gateway" + "github.com/Rain-kl/Wavelet/plugins/domain/risk_control" + "github.com/Rain-kl/Wavelet/plugins/domain/user" + "github.com/Rain-kl/Wavelet/plugins/infra/cache" + "github.com/Rain-kl/Wavelet/plugins/infra/database" + "github.com/Rain-kl/Wavelet/plugins/infra/logger" + "github.com/Rain-kl/Wavelet/plugins/infra/storage" +) + +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "domain_test.db") + testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + + require.NoError(t, testDB.AutoMigrate( + &model.User{}, + &model.AccessToken{}, + &model.AuthSource{}, + &model.ExternalAccount{}, + &model.MessageChannel{}, + &model.MessageBinding{}, + &model.MessagePairingCode{}, + &model.SystemConfig{}, + &model.PushChannel{}, + &model.PushEvent{}, + &model.PushHistory{}, + )) + + db.SetDB(testDB) + return testDB +} + +type mockOAuthProvider struct { + name string +} + +func (m *mockOAuthProvider) Name() string { + return m.name +} + +func (m *mockOAuthProvider) GetAuthURL(state string) string { + return "https://oauth.example.com/auth?state=" + state +} + +func (m *mockOAuthProvider) ExchangeCode(ctx context.Context, code string) (*contracts.OAuthUserInfoDTO, error) { + return &contracts.OAuthUserInfoDTO{ + ID: 1001, + Username: "mock_user", + Email: "mock@example.com", + Active: true, + }, nil +} + +func TestAuthPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + testDB := setupTestDB(t) + + require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New().Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + + p := auth.New() + assert.Equal(t, "auth", p.Name()) + assert.Equal(t, "auth", p.Manifest().Name) + require.NoError(t, p.Apply(ctx)) + + // 1. Verify migrations registered + entry, ok := ctx.Migrations().Get("auth") + require.True(t, ok) + assert.Equal(t, "auth", entry.PluginID) + entries, err := fs.ReadDir(entry.FS, entry.Dir) + require.NoError(t, err) + assert.NotEmpty(t, entries) + + // 2. Verify AuthService + authSvc, err := core.Inject[contracts.AuthService](ctx) + require.NoError(t, err) + require.NotNil(t, authSvc) + assert.NotNil(t, authSvc.RequireAuthMiddleware()) + assert.NotNil(t, authSvc.RequireAdminMiddleware()) + + // 3. Verify AuthRegistry + authReg, err := core.Inject[contracts.AuthRegistry](ctx) + require.NoError(t, err) + require.NotNil(t, authReg) + + mockProv := &mockOAuthProvider{name: "github"} + authReg.RegisterOAuthProvider("github", mockProv) + retrieved, ok := authReg.GetOAuthProvider("github") + require.True(t, ok) + assert.Equal(t, "github", retrieved.Name()) + assert.Contains(t, authReg.ListOAuthProviders(), "github") + + // 4. Verify Routes + routes := ctx.Router().Routes() + var hasSources, hasLogin, hasUserInfo bool + for _, r := range routes { + if r.Path == "/api/v1/oauth/sources" { + hasSources = true + } + if r.Path == "/api/v1/oauth/login" { + hasLogin = true + } + if r.Path == "/api/v1/user-info" { + hasUserInfo = true + } + } + assert.True(t, hasSources) + assert.True(t, hasLogin) + assert.True(t, hasUserInfo) + + // 5. Verify Settings + schema, ok := ctx.Settings().Get("auth.session_age") + require.True(t, ok) + assert.Equal(t, 86400*7, schema.Default) +} + +func TestUserPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + testDB := setupTestDB(t) + + require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New().Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + + p := user.New() + assert.Equal(t, "user", p.Name()) + assert.Equal(t, "user", p.Manifest().Name) + require.NoError(t, p.Apply(ctx)) + + // 1. Verify migrations + entry, ok := ctx.Migrations().Get("user") + require.True(t, ok) + assert.Equal(t, "user", entry.PluginID) + + // 2. Verify UserService + userSvc, err := core.Inject[contracts.UserService](ctx) + require.NoError(t, err) + require.NotNil(t, userSvc) + + testCtx := context.Background() + + // 3. Create user + created, err := userSvc.CreateUser(testCtx, contracts.CreateUserRequest{ + Username: "bob", + Password: "SecurePassword123!", + Nickname: "Bob Builder", + Email: "bob@example.com", + IsAdmin: false, + }) + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, "bob", created.Username) + assert.Equal(t, "Bob Builder", created.Nickname) + assert.Equal(t, "bob@example.com", created.Email) + assert.False(t, created.IsAdmin) + + // 4. Query user + byID, err := userSvc.GetUserByID(testCtx, created.ID) + require.NoError(t, err) + assert.Equal(t, "bob", byID.Username) + + byUsername, err := userSvc.GetUserByUsername(testCtx, "bob") + require.NoError(t, err) + assert.Equal(t, created.ID, byUsername.ID) + + byEmail, err := userSvc.GetUserByEmail(testCtx, "bob@example.com") + require.NoError(t, err) + assert.Equal(t, created.ID, byEmail.ID) + + // 5. Password verification and update + assert.True(t, userSvc.VerifyPassword(testCtx, created.ID, "SecurePassword123!")) + assert.False(t, userSvc.VerifyPassword(testCtx, created.ID, "WrongPass")) + + require.NoError(t, userSvc.UpdatePassword(testCtx, created.ID, "SecurePassword123!", "NewSecurePassword456!")) + assert.True(t, userSvc.VerifyPassword(testCtx, created.ID, "NewSecurePassword456!")) + + // 6. Update Profile + newBio := "I build things" + newPhone := "13800138000" + updated, err := userSvc.UpdateProfile(testCtx, created.ID, contracts.UpdateUserProfileRequest{ + Bio: &newBio, + Phone: &newPhone, + }) + require.NoError(t, err) + assert.Equal(t, newBio, updated.Bio) + assert.Equal(t, newPhone, updated.Phone) + + // 7. Update Last Login + require.NoError(t, userSvc.UpdateLastLogin(testCtx, created.ID, "127.0.0.1")) + + // 8. Admin operations: SetUserActive, SetUserAdmin, ListUsers + require.NoError(t, userSvc.SetUserAdmin(testCtx, created.ID, true)) + reloaded, err := userSvc.GetUserByID(testCtx, created.ID) + require.NoError(t, err) + assert.True(t, reloaded.IsAdmin) + + require.NoError(t, userSvc.SetUserActive(testCtx, created.ID, false)) + reloadedBanned, err := userSvc.GetUserByID(testCtx, created.ID) + require.NoError(t, err) + assert.False(t, reloadedBanned.IsActive) + + list, total, err := userSvc.ListUsers(testCtx, 1, 10, "bob") + require.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Len(t, list, 1) + assert.Equal(t, "bob", list[0].Username) + + // 9. Tasks & Schedules + taskDef, ok := ctx.Tasks().Get("user:send_email_code") + require.True(t, ok) + assert.Equal(t, 3, taskDef.Retry) + + schedDef, ok := ctx.Schedules().Get("user:daily_audit") + require.True(t, ok) + assert.Equal(t, "0 3 * * *", schedDef.Spec) + + // 10. Settings + sReg, ok := ctx.Settings().Get("user.registration_enabled") + require.True(t, ok) + assert.Equal(t, true, sReg.Default) +} + +func TestMessageGatewayPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + testDB := setupTestDB(t) + + require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New().Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + + p := message_gateway.New() + assert.Equal(t, "message_gateway", p.Name()) + assert.Equal(t, "message_gateway", p.Manifest().Name) + require.NoError(t, p.Apply(ctx)) + + // 1. Migrations + entry, ok := ctx.Migrations().Get("message_gateway") + require.True(t, ok) + assert.Equal(t, "message_gateway", entry.PluginID) + + // 2. Routes + routes := ctx.Router().Routes() + var hasChannels, hasBindings bool + for _, r := range routes { + if r.Path == "/api/v1/message-gateway/channels" { + hasChannels = true + } + if r.Path == "/api/v1/message-gateway/bindings" { + hasBindings = true + } + } + assert.True(t, hasChannels) + assert.True(t, hasBindings) + + // 3. Tasks & Schedules + taskDef, ok := ctx.Tasks().Get("message_gateway:push_notification") + require.True(t, ok) + assert.Equal(t, 3, taskDef.Retry) + + schedDef, ok := ctx.Schedules().Get("message_gateway:cleanup_pairing_codes") + require.True(t, ok) + assert.Equal(t, "*/10 * * * *", schedDef.Spec) + + // 4. EventBus Trigger + var receivedEvent message_gateway.PushNotificationEvent + var eventFired bool + ctx.Events().On("notification:push", func(c context.Context, e message_gateway.PushNotificationEvent) error { + eventFired = true + receivedEvent = e + return nil + }) + + err := ctx.Events().Emit(context.Background(), "notification:push", message_gateway.PushNotificationEvent{ + UserID: 99, + Channel: "telegram", + Title: "System Alert", + Content: "Disk 85% full", + }) + require.NoError(t, err) + assert.True(t, eventFired) + assert.Equal(t, uint64(99), receivedEvent.UserID) + assert.Equal(t, "telegram", receivedEvent.Channel) + assert.Equal(t, "System Alert", receivedEvent.Title) + + // 5. Settings + schema, ok := ctx.Settings().Get("message_gateway.pairing_code_expiry_minutes") + require.True(t, ok) + assert.Equal(t, 15, schema.Default) +} + +func TestRiskControlPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + p := risk_control.New() + assert.Equal(t, "risk_control", p.Name()) + assert.Equal(t, "risk_control", p.Manifest().Name) + require.NoError(t, p.Apply(ctx)) + + // 1. Middleware registered on Router + mws := ctx.Router().Middlewares() + assert.NotEmpty(t, mws) + + // 2. Settings + schema, ok := ctx.Settings().Get("risk_control.ip_rate_limit_per_minute") + require.True(t, ok) + assert.Equal(t, 60, schema.Default) + + // 3. Disposal cleanup + require.NoError(t, ctx.Dispose()) +} + +func TestAdminPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + testDB := setupTestDB(t) + + require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New().Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + + p := admin.New() + assert.Equal(t, "admin", p.Name()) + assert.Equal(t, "admin", p.Manifest().Name) + require.NoError(t, p.Apply(ctx)) + + // 1. Admin Routes + routes := ctx.Router().Routes() + var hasStatus, hasDBOverview, hasUsers, hasTasks, hasPushEvents bool + for _, r := range routes { + if r.Path == "/api/v1/admin/status" { + hasStatus = true + } + if r.Path == "/api/v1/admin/db-manage/overview" { + hasDBOverview = true + } + if r.Path == "/api/v1/admin/users" { + hasUsers = true + } + if r.Path == "/api/v1/admin/tasks/types" { + hasTasks = true + } + if r.Path == "/api/v1/admin/push/events" { + hasPushEvents = true + } + } + assert.True(t, hasStatus) + assert.True(t, hasDBOverview) + assert.True(t, hasUsers) + assert.True(t, hasTasks) + assert.True(t, hasPushEvents) + + // 2. Task & Schedule + _, ok := ctx.Tasks().Get("admin:system_cleanup") + require.True(t, ok) + sched, ok := ctx.Schedules().Get("admin:system_cleanup") + require.True(t, ok) + assert.Equal(t, "0 4 * * *", sched.Spec) + + // 3. Settings + schema, ok := ctx.Settings().Get("admin.system_cleanup_cron") + require.True(t, ok) + assert.Equal(t, "0 4 * * *", schema.Default) +} + +func TestAllDomainPluginsCombined(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer func() { _ = rdb.Close() }() + + ctx := core.NewContext(context.Background()) + testDB := setupTestDB(t) + + // Apply Infra plugins + require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New(cache.WithRedis(rdb)).Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + require.NoError(t, storage.New().Apply(ctx)) + + // Apply Domain plugins + require.NoError(t, auth.New().Apply(ctx)) + require.NoError(t, user.New().Apply(ctx)) + require.NoError(t, message_gateway.New().Apply(ctx)) + require.NoError(t, risk_control.New().Apply(ctx)) + require.NoError(t, admin.New().Apply(ctx)) + + // Verify cross-plugin service injection via Using3 + var resolved bool + err = core.Using3(ctx, func(authSvc contracts.AuthService, userSvc contracts.UserService, authReg contracts.AuthRegistry) { + resolved = true + assert.NotNil(t, authSvc) + assert.NotNil(t, userSvc) + assert.NotNil(t, authReg) + }) + require.NoError(t, err) + assert.True(t, resolved) + + // Verify all migration entries + allMigrations := ctx.Migrations().Entries() + assert.GreaterOrEqual(t, len(allMigrations), 3) + + // Verify total routes registered + allRoutes := ctx.Router().Routes() + assert.GreaterOrEqual(t, len(allRoutes), 20) + + // Verify total tasks registered + allTasks := ctx.Tasks().Tasks() + assert.GreaterOrEqual(t, len(allTasks), 4) + + // Verify total schedules registered + allSchedules := ctx.Schedules().Schedules() + assert.GreaterOrEqual(t, len(allSchedules), 3) + + // Verify total settings schemas registered + allSettings := ctx.Settings().Schemas() + assert.GreaterOrEqual(t, len(allSettings), 7) + + // Clean shutdown + require.NoError(t, ctx.Dispose()) +} diff --git a/plugins/domain/message_gateway/migrations/20260827000001_create_message_gateway_tables.sql b/plugins/domain/message_gateway/migrations/20260827000001_create_message_gateway_tables.sql new file mode 100644 index 00000000..36465fc4 --- /dev/null +++ b/plugins/domain/message_gateway/migrations/20260827000001_create_message_gateway_tables.sql @@ -0,0 +1,44 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_message_channels ( + id BIGINT PRIMARY KEY, + name VARCHAR(128) NOT NULL, + type VARCHAR(32) NOT NULL, + owner_scope VARCHAR(16) NOT NULL DEFAULT 'system', + owner_id BIGINT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + credentials TEXT NOT NULL DEFAULT '', + extra TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_message_channels_type ON w_message_channels (type); + +CREATE TABLE IF NOT EXISTS w_message_bindings ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + platform_user_id VARCHAR(128) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_message_bindings_channel_platform + ON w_message_bindings (channel_id, platform_user_id); +CREATE INDEX IF NOT EXISTS idx_w_message_bindings_user ON w_message_bindings (user_id); + +CREATE TABLE IF NOT EXISTS w_message_pairing_codes ( + code VARCHAR(16) PRIMARY KEY, + channel_id BIGINT NOT NULL, + platform_user_id VARCHAR(128) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_message_pairing_lookup + ON w_message_pairing_codes (channel_id, platform_user_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_message_pairing_codes; +DROP TABLE IF EXISTS w_message_bindings; +DROP TABLE IF EXISTS w_message_channels; +-- +goose StatementEnd diff --git a/plugins/domain/message_gateway/plugin.go b/plugins/domain/message_gateway/plugin.go new file mode 100644 index 00000000..89a247a0 --- /dev/null +++ b/plugins/domain/message_gateway/plugin.go @@ -0,0 +1,108 @@ +// Package message_gateway provides the Bot gateway, multi-channel notification dispatching, and asynchronous push worker domain plugin for Cordis. +package message_gateway + +import ( + "context" + "embed" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/extpoints" + appgw "github.com/Rain-kl/Wavelet/internal/apps/message_gateway" + "github.com/Rain-kl/Wavelet/internal/apps/oauth" + "github.com/hibiken/asynq" +) + +//go:embed migrations/*.sql +var mgMigrations embed.FS + +// Option configures the message_gateway plugin. +type Option func(*Plugin) + +// Plugin implements core.Plugin to provide Bot gateway and notification dispatch domain services. +type Plugin struct{} + +// New creates a new message_gateway domain plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier for the message_gateway domain plugin. +func (p *Plugin) Name() string { + return "message_gateway" +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "message_gateway", + Version: "1.0.0", + Description: "Bot gateway, multi-channel notification push, and async worker dispatch plugin", + Author: "Wavelet Team", + } +} + +// PushNotificationEvent defines the payload for eventbus notification trigger. +type PushNotificationEvent struct { + UserID uint64 `json:"user_id"` + Channel string `json:"channel"` + Title string `json:"title"` + Content string `json:"content"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// Apply registers message_gateway migrations, routes, tasks, schedules, events, and settings into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. Register migrations + ctx.Migrations().Register("message_gateway", mgMigrations) + + // 2. Register HTTP Routes + mgGroup := ctx.Router().Group("/api/v1/message-gateway", oauth.LoginRequired()) + { + mgGroup.GET("/channels", appgw.ListChannels) + mgGroup.GET("/bindings", appgw.ListBindings) + mgGroup.POST("/bindings", appgw.BindBinding) + mgGroup.DELETE("/bindings/:id", appgw.UnbindBinding) + } + + // 3. Register Asynq background tasks + ctx.Task().Register("message_gateway:push_notification", func(c context.Context, t *asynq.Task) error { + return nil + }, extpoints.WithTaskRetry(3)) + + ctx.Task().Register("message_gateway:dispatch_bot_msg", func(c context.Context, t *asynq.Task) error { + return nil + }) + + // 4. Register Cron Schedules + ctx.Schedule().RegisterCron("*/10 * * * *", "message_gateway:cleanup_pairing_codes", map[string]any{"action": "cleanup"}) + + // 5. Register EventBus listeners for decoupled push triggers + ctx.Events().On("notification:push", func(c context.Context, e PushNotificationEvent) error { + // Event triggered push handling + return nil + }) + + // 6. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "message_gateway.pairing_code_expiry_minutes", + Default: 15, + Description: "Expiry duration for bot pairing codes in minutes", + Type: "integer", + Category: "messaging", + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "message_gateway.max_bindings_per_user", + Default: 5, + Description: "Maximum platform bot bindings per user", + Type: "integer", + Category: "messaging", + }) + + return nil +} diff --git a/plugins/domain/message_gateway/plugin_test.go b/plugins/domain/message_gateway/plugin_test.go new file mode 100644 index 00000000..0f098197 --- /dev/null +++ b/plugins/domain/message_gateway/plugin_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway_test + +import ( + "context" + "io/fs" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/plugins/domain/message_gateway" +) + +func TestMessageGatewayPluginUnit(t *testing.T) { + ctx := core.NewContext(context.Background()) + p := message_gateway.New() + assert.Equal(t, "message_gateway", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + // Verify migrations + entry, ok := ctx.Migrations().Get("message_gateway") + require.True(t, ok) + entries, err := fs.ReadDir(entry.FS, entry.Dir) + require.NoError(t, err) + assert.NotEmpty(t, entries) + + // Verify tasks + task, ok := ctx.Tasks().Get("message_gateway:push_notification") + require.True(t, ok) + assert.Equal(t, 3, task.Retry) + + // Verify schedules + sched, ok := ctx.Schedules().Get("message_gateway:cleanup_pairing_codes") + require.True(t, ok) + assert.Equal(t, "*/10 * * * *", sched.Spec) + + // Verify settings + setting, ok := ctx.Settings().Get("message_gateway.max_bindings_per_user") + require.True(t, ok) + assert.Equal(t, 5, setting.Default) +} diff --git a/plugins/domain/risk_control/plugin.go b/plugins/domain/risk_control/plugin.go new file mode 100644 index 00000000..05055875 --- /dev/null +++ b/plugins/domain/risk_control/plugin.go @@ -0,0 +1,88 @@ +// Package risk_control provides the access control, IP rate limiting, and telemetry risk analysis domain plugin for Cordis. +package risk_control + +import ( + "context" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/extpoints" + "github.com/Rain-kl/Wavelet/internal/apps/risk_control" + "github.com/gin-gonic/gin" +) + +// Option configures the risk_control plugin. +type Option func(*Plugin) + +// WithMiddleware configures a custom risk control middleware. +func WithMiddleware(mw gin.HandlerFunc) Option { + return func(p *Plugin) { + p.middleware = mw + } +} + +// Plugin implements core.Plugin to provide risk control and access logging middleware. +type Plugin struct { + middleware gin.HandlerFunc +} + +// New creates a new risk_control domain plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier for the risk_control domain plugin. +func (p *Plugin) Name() string { + return "risk_control" +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "risk_control", + Version: "1.0.0", + Description: "Access control, IP rate limiting, and access log telemetry domain plugin", + Author: "Wavelet Team", + } +} + +// Apply registers risk control middlewares, settings, and cleanup hooks into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. Initialize LogWriter if needed + risk_control.InitLogWriter(ctx.GoContext()) + + // 2. Register router middleware + mw := p.middleware + if mw == nil { + mw = risk_control.RiskControlMiddleware() + } + ctx.Router().Use(mw) + + // 3. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "risk_control.ip_rate_limit_per_minute", + Default: 60, + Description: "Maximum requests allowed per IP per minute", + Type: "integer", + Category: "security", + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "risk_control.enable_access_log", + Default: true, + Description: "Enable structured access log auditing and backpressure queueing", + Type: "boolean", + Category: "security", + }) + + // 4. Register lifecycle disposal cleanup + ctx.OnDispose(func() error { + return risk_control.StopLogWriter(context.Background()) + }) + + return nil +} diff --git a/plugins/domain/risk_control/plugin_test.go b/plugins/domain/risk_control/plugin_test.go new file mode 100644 index 00000000..4ab89607 --- /dev/null +++ b/plugins/domain/risk_control/plugin_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package risk_control_test + +import ( + "context" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/plugins/domain/risk_control" +) + +func TestRiskControlPluginUnit(t *testing.T) { + ctx := core.NewContext(context.Background()) + var customMWCalled bool + customMW := func(c *gin.Context) { + customMWCalled = true + c.Next() + } + + p := risk_control.New(risk_control.WithMiddleware(customMW)) + assert.Equal(t, "risk_control", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + // Verify middlewares registered + mws := ctx.Router().Middlewares() + assert.NotEmpty(t, mws) + + // Verify settings + setting, ok := ctx.Settings().Get("risk_control.enable_access_log") + require.True(t, ok) + assert.Equal(t, true, setting.Default) + + require.NoError(t, ctx.Dispose()) + assert.False(t, customMWCalled) // not dispatched via gin engine here +} diff --git a/plugins/domain/user/migrations/20260827000001_create_user_tables.sql b/plugins/domain/user/migrations/20260827000001_create_user_tables.sql new file mode 100644 index 00000000..c1ca0463 --- /dev/null +++ b/plugins/domain/user/migrations/20260827000001_create_user_tables.sql @@ -0,0 +1,44 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_users ( + id BIGINT PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password VARCHAR(255), + nickname VARCHAR(255), + email VARCHAR(255), + avatar_url VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + is_admin BOOLEAN DEFAULT FALSE, + bio VARCHAR(500), + phone VARCHAR(32), + gender VARCHAR(16), + website VARCHAR(255), + location VARCHAR(255), + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_users_email ON w_users (email); +CREATE INDEX IF NOT EXISTS idx_w_users_is_active ON w_users (is_active); +CREATE INDEX IF NOT EXISTS idx_w_users_last_login_at ON w_users (last_login_at); +CREATE INDEX IF NOT EXISTS idx_w_users_created_at ON w_users (created_at); + +CREATE TABLE IF NOT EXISTS w_access_tokens ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description VARCHAR(255), + is_admin BOOLEAN DEFAULT FALSE, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_access_tokens_user_id ON w_access_tokens (user_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_access_tokens; +DROP TABLE IF EXISTS w_users; +-- +goose StatementEnd diff --git a/plugins/domain/user/plugin.go b/plugins/domain/user/plugin.go new file mode 100644 index 00000000..bda5584f --- /dev/null +++ b/plugins/domain/user/plugin.go @@ -0,0 +1,130 @@ +// Package user provides the user profile, credential management, role management, and access token domain plugin for Cordis. +package user + +import ( + "context" + "embed" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/core/extpoints" + "github.com/Rain-kl/Wavelet/internal/apps/oauth" + "github.com/Rain-kl/Wavelet/internal/apps/user" + "github.com/hibiken/asynq" +) + +//go:embed migrations/*.sql +var userMigrations embed.FS + +// Option configures the user plugin. +type Option func(*Plugin) + +// WithUserService sets a custom UserService implementation. +func WithUserService(svc contracts.UserService) Option { + return func(p *Plugin) { + p.userSvc = svc + } +} + +// Plugin implements core.Plugin to provide user account and credential domain services. +type Plugin struct { + userSvc contracts.UserService +} + +// New creates a new user domain plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier for the user domain plugin. +func (p *Plugin) Name() string { + return "user" +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "user", + Version: "1.0.0", + Description: "User profiles, credentials, role management, and access token domain plugin", + Author: "Wavelet Team", + } +} + +// Apply registers user migrations, services, routes, tasks, schedules, and settings into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. Register migrations + ctx.Migrations().Register("user", userMigrations) + + // 2. Initialize and provide UserService + if p.userSvc == nil { + p.userSvc = newUserService() + } + core.Provide[contracts.UserService](ctx, p.userSvc) + + // 3. Register HTTP Routes + userGroup := ctx.Router().Group("/api/v1/user") + { + userGroup.POST("/login", user.Login) + userGroup.POST("/register", user.Register) + userGroup.GET("/logout", user.Logout) + userGroup.POST("/send-email-code", user.SendEmailCode) + userGroup.POST("/change-password", oauth.LoginRequired(), user.ChangePassword) + userGroup.PUT("/profile", oauth.LoginRequired(), user.UpdateProfile) + + // Access Tokens + tokensGroup := userGroup.Group("/access-tokens", oauth.LoginRequired(), oauth.DisallowTokenAuth()) + { + tokensGroup.GET("", user.ListAccessTokens) + tokensGroup.POST("", user.CreateAccessToken) + tokensGroup.DELETE("/:id", user.DeleteAccessToken) + tokensGroup.POST("/:id/rotate", user.RotateAccessToken) + } + } + + // 4. Register Asynq background tasks + ctx.Task().Register("user:send_email_code", func(c context.Context, t *asynq.Task) error { + // Asynq background task handler + return nil + }, extpoints.WithTaskRetry(3)) + + ctx.Task().Register("user:cleanup_inactive", func(c context.Context, t *asynq.Task) error { + return nil + }) + + // 5. Register Cron Schedules + ctx.Schedule().RegisterCron("0 3 * * *", "user:daily_audit", map[string]string{"type": "audit"}) + + // 6. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "user.registration_enabled", + Default: true, + Description: "Whether new user registration is enabled", + Type: "boolean", + Category: "general", + Public: true, + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "user.password_login_enabled", + Default: true, + Description: "Whether password login is enabled", + Type: "boolean", + Category: "general", + Public: true, + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "user.min_password_length", + Default: 8, + Description: "Minimum password length required for user accounts", + Type: "integer", + Category: "security", + }) + + return nil +} diff --git a/plugins/domain/user/plugin_test.go b/plugins/domain/user/plugin_test.go new file mode 100644 index 00000000..3c13639b --- /dev/null +++ b/plugins/domain/user/plugin_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/contracts" + db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/plugins/domain/user" +) + +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "user_test.db") + testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + + require.NoError(t, testDB.AutoMigrate( + &model.User{}, + &model.AccessToken{}, + )) + + db.SetDB(testDB) + return testDB +} + +func TestUserPluginUnit(t *testing.T) { + ctx := core.NewContext(context.Background()) + _ = setupTestDB(t) + + p := user.New() + assert.Equal(t, "user", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + userSvc, err := core.Inject[contracts.UserService](ctx) + require.NoError(t, err) + require.NotNil(t, userSvc) + + testCtx := context.Background() + + // 1. Create User + u, err := userSvc.CreateUser(testCtx, contracts.CreateUserRequest{ + Username: "charlie", + Password: "Password789!", + Email: "charlie@example.com", + }) + require.NoError(t, err) + assert.Equal(t, "charlie", u.Username) + + // 2. Empty username error + _, err = userSvc.CreateUser(testCtx, contracts.CreateUserRequest{}) + assert.Error(t, err) + + // 3. Verify Password + assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "Password789!")) + assert.False(t, userSvc.VerifyPassword(testCtx, u.ID, "Wrong")) + + // 4. Update Password with wrong old password + err = userSvc.UpdatePassword(testCtx, u.ID, "WrongOld", "NewPass999!") + assert.Error(t, err) + + // Update Password success + err = userSvc.UpdatePassword(testCtx, u.ID, "Password789!", "NewPass999!") + require.NoError(t, err) + assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "NewPass999!")) + + // 5. Update Profile + nickname := "Charlie Brown" + email := "charlie.new@example.com" + gender := "male" + website := "https://charlie.me" + loc := "SF" + updated, err := userSvc.UpdateProfile(testCtx, u.ID, contracts.UpdateUserProfileRequest{ + Nickname: &nickname, + Email: &email, + Gender: &gender, + Website: &website, + Location: &loc, + }) + require.NoError(t, err) + assert.Equal(t, "Charlie Brown", updated.Nickname) + assert.Equal(t, "charlie.new@example.com", updated.Email) + assert.Equal(t, "male", updated.Gender) + assert.Equal(t, "https://charlie.me", updated.Website) + assert.Equal(t, "SF", updated.Location) + + // 6. List and Status + require.NoError(t, userSvc.SetUserAdmin(testCtx, u.ID, true)) + require.NoError(t, userSvc.SetUserActive(testCtx, u.ID, true)) + + list, total, err := userSvc.ListUsers(testCtx, 1, 10, "") + require.NoError(t, err) + assert.GreaterOrEqual(t, total, int64(1)) + assert.NotEmpty(t, list) +} diff --git a/plugins/domain/user/service.go b/plugins/domain/user/service.go new file mode 100644 index 00000000..c4db94cd --- /dev/null +++ b/plugins/domain/user/service.go @@ -0,0 +1,210 @@ +// Package user provides user profiles, credentials, role management, and access token domain services. +package user + +import ( + "context" + "errors" + "time" + + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/repository" + pkgu "github.com/Rain-kl/Wavelet/pkg/util" +) + +func toUserDTO(u *model.User) *contracts.UserDTO { + if u == nil { + return nil + } + return &contracts.UserDTO{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Email: u.Email, + AvatarURL: u.AvatarURL, + IsActive: u.IsActive, + IsAdmin: u.IsAdmin, + Bio: u.Bio, + Phone: u.Phone, + Gender: u.Gender, + Website: u.Website, + Location: u.Location, + LastLoginAt: u.LastLoginAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +type userServiceImpl struct{} + +func newUserService() contracts.UserService { + return &userServiceImpl{} +} + +func (s *userServiceImpl) GetUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) { + u, err := repository.GetUserByID(ctx, id) + if err != nil { + return nil, err + } + return toUserDTO(&u), nil +} + +func (s *userServiceImpl) GetUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) { + u, err := repository.GetUserByUsername(ctx, username) + if err != nil { + return nil, err + } + return toUserDTO(&u), nil +} + +func (s *userServiceImpl) GetUserByEmail(ctx context.Context, email string) (*contracts.UserDTO, error) { + var u model.User + if err := db.DB(ctx).Where("email = ?", email).First(&u).Error; err != nil { + return nil, err + } + return toUserDTO(&u), nil +} + +func (s *userServiceImpl) CreateUser(ctx context.Context, req contracts.CreateUserRequest) (*contracts.UserDTO, error) { + if req.Username == "" { + return nil, errors.New("user: username cannot be empty") + } + + user := model.User{ + ID: idgen.NextUint64ID(), + Username: req.Username, + Nickname: req.Nickname, + Email: req.Email, + IsActive: true, + IsAdmin: req.IsAdmin, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + LastLoginAt: time.Now(), + } + + if user.Nickname == "" { + user.Nickname = req.Username + } + + if req.Password != "" { + if err := user.SetEncryptedPassword(req.Password); err != nil { + return nil, err + } + } + + if err := repository.CreateUser(ctx, &user); err != nil { + return nil, err + } + + return toUserDTO(&user), nil +} + +func (s *userServiceImpl) UpdateProfile(ctx context.Context, id uint64, req contracts.UpdateUserProfileRequest) (*contracts.UserDTO, error) { + updates := make(map[string]any) + if req.Nickname != nil { + updates["nickname"] = *req.Nickname + } + if req.Email != nil { + updates["email"] = *req.Email + } + if req.AvatarURL != nil { + updates["avatar_url"] = *req.AvatarURL + } + if req.Bio != nil { + updates["bio"] = *req.Bio + } + if req.Phone != nil { + updates["phone"] = *req.Phone + } + if req.Gender != nil { + updates["gender"] = *req.Gender + } + if req.Website != nil { + updates["website"] = *req.Website + } + if req.Location != nil { + updates["location"] = *req.Location + } + updates["updated_at"] = time.Now() + + if err := db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return nil, err + } + + return s.GetUserByID(ctx, id) +} + +func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error { + var user model.User + if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil { + return err + } + + if !user.CheckPassword(oldPassword) { + return errors.New("user: incorrect old password") + } + + if err := user.SetEncryptedPassword(newPassword); err != nil { + return err + } + + return db.DB(ctx).Model(&model.User{}).Where("id = ?", id). + Updates(map[string]any{ + "password": user.Password, + "updated_at": time.Now(), + }).Error +} + +func (s *userServiceImpl) VerifyPassword(ctx context.Context, id uint64, password string) bool { + var user model.User + if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil { + pkgu.DummyCheckPassword(password) + return false + } + return user.CheckPassword(password) +} + +func (s *userServiceImpl) UpdateLastLogin(ctx context.Context, id uint64, ip string) error { + return db.DB(ctx).Model(&model.User{}).Where("id = ?", id). + Updates(map[string]any{ + "last_login_at": time.Now(), + "updated_at": time.Now(), + }).Error +} + +func (s *userServiceImpl) ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*contracts.UserDTO, int64, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + filter := repository.AdminUserListFilter{ + Username: keyword, + Page: page, + PageSize: pageSize, + } + + total, users, err := repository.ListAdminUsers(ctx, filter) + if err != nil { + return nil, 0, err + } + + dtos := make([]*contracts.UserDTO, 0, len(users)) + for i := range users { + dtos = append(dtos, toUserDTO(&users[i])) + } + + return dtos, total, nil +} + +func (s *userServiceImpl) SetUserActive(ctx context.Context, id uint64, active bool) error { + return repository.UpdateUserActive(ctx, id, active) +} + +func (s *userServiceImpl) SetUserAdmin(ctx context.Context, id uint64, admin bool) error { + return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Update("is_admin", admin).Error +}