Compare commits

...

43 Commits

Author SHA1 Message Date
Rostislav Dugin
da5c13fb11 Merge pull request #356 from databasus/develop
FIX (mysql & mariadb): Fix creation of backups with exremely large SQ…
2026-02-10 22:40:06 +03:00
Rostislav Dugin
35180360e5 FIX (mysql & mariadb): Fix creation of backups with exremely large SQL statements to avoid OOM 2026-02-10 22:38:18 +03:00
Rostislav Dugin
e4f6cd7a5d Merge pull request #349 from databasus/develop
Develop
2026-02-09 16:42:00 +03:00
Rostislav Dugin
d7b8e6d56a Merge branch 'develop' of https://github.com/databasus/databasus into develop 2026-02-09 16:40:46 +03:00
Rostislav Dugin
6016f23fb2 FEATURE (svr): Add SVR support 2026-02-09 16:39:51 +03:00
Rostislav Dugin
e7c4ee8f6f Merge pull request #345 from databasus/develop
Develop
2026-02-08 23:38:42 +03:00
Rostislav Dugin
a75702a01b Merge pull request #342 from wuast94/patch-1
Add image source label to dockerfiles
2026-02-08 23:38:18 +03:00
Rostislav Dugin
81a21eb907 FEATURE (google drive): Change OAuth authorization flow to local address instead of databasus.com 2026-02-08 23:32:13 +03:00
Marc
33d6bf0147 Add image source label to dockerfiles
To get changelogs shown with Renovate a docker container has to add the source label described in the OCI Image Format Specification.

For reference: https://github.com/renovatebot/renovate/blob/main/lib/modules/datasource/docker/readme.md
2026-02-05 23:30:37 +01:00
Rostislav Dugin
6eb53bb07b Merge pull request #341 from databasus/develop
Develop
2026-02-06 00:25:30 +03:00
Rostislav Dugin
6ac04270b9 FEATURE (healthcheck): Add checking whether backup nodes available for primary node 2026-02-06 00:24:34 +03:00
Rostislav Dugin
b0510d7c21 FIX (logging): Add login to VictoriaLogs logger 2026-02-06 00:18:09 +03:00
Rostislav Dugin
dc5f271882 Merge pull request #339 from databasus/develop
FIX (storages): Do not remove system storage on any workspace deletion
2026-02-05 01:32:46 +03:00
Rostislav Dugin
8f718771c9 FIX (storages): Do not remove system storage on any workspace deletion 2026-02-05 01:32:21 +03:00
Rostislav Dugin
d8eea05dca Merge pull request #332 from databasus/develop
FIX (script): Fix script creation in playground head x2
2026-02-02 20:46:35 +03:00
Rostislav Dugin
b2a94274d7 FIX (script): Fix script creation in playground head x2 2026-02-02 20:44:52 +03:00
Rostislav Dugin
77c2712ebb Merge pull request #331 from databasus/develop
FIX (script): Fix script creation in playground head
2026-02-02 19:47:44 +03:00
Rostislav Dugin
a9dc29f82c FIX (script): Fix script creation in playground head 2026-02-02 19:47:15 +03:00
Rostislav Dugin
c934a45dca Merge pull request #330 from databasus/develop
FIX (storages): Fix storage edit in playground
2026-02-02 18:51:47 +03:00
Rostislav Dugin
d4acdf2826 FIX (storages): Fix storage edit in playground 2026-02-02 18:48:19 +03:00
Rostislav Dugin
49753c4fc0 Merge pull request #329 from databasus/develop
FIX (s3): Fix S3 prefill in playground on form edit
2026-02-02 18:14:07 +03:00
Rostislav Dugin
c6aed6b36d FIX (s3): Fix S3 prefill in playground on form edit 2026-02-02 18:12:44 +03:00
Rostislav Dugin
3060b4266a Merge pull request #328 from databasus/develop
Develop
2026-02-02 17:53:05 +03:00
Rostislav Dugin
ebeb597f17 FEATURE (playground): Add support of Rybbit script for playground 2026-02-02 17:50:31 +03:00
Rostislav Dugin
4783784325 FIX (playground): Do not show whitelist message in playground 2026-02-02 16:53:01 +03:00
Rostislav Dugin
bd41433bdb Merge branch 'develop' of https://github.com/databasus/databasus into develop 2026-02-02 16:50:18 +03:00
Rostislav Dugin
a9073787d2 FIX (audit logs): In dark mode show white text in audit logs 2026-02-02 16:44:49 +03:00
Rostislav Dugin
0890bf8f09 Merge pull request #327 from artemkalugin01/access-management-href-fix
Fix href in settings for access-management#global-settings
2026-02-02 16:12:25 +03:00
artem.kalugin
f8c11e8802 Fix href typo in settings for access-management#global-settings 2026-02-02 12:59:56 +03:00
Rostislav Dugin
e798d82fc1 Merge pull request #325 from databasus/develop
FIX (storages): Fix default storage type prefill in playground
2026-02-01 20:12:12 +03:00
Rostislav Dugin
81a01585ee FIX (storages): Fix default storage type prefill in playground 2026-02-01 20:07:12 +03:00
Rostislav Dugin
a8465c1a10 Merge pull request #324 from databasus/develop
FIX (storages): Limit local storage usage in playground
2026-02-01 19:20:34 +03:00
Rostislav Dugin
a9e5db70f6 FIX (storages): Limit local storage usage in playground 2026-02-01 19:18:54 +03:00
Rostislav Dugin
7a47be6ca6 Merge pull request #323 from databasus/develop
Develop
2026-02-01 18:42:30 +03:00
Rostislav Dugin
16be3db0c6 FIX (playground): Pre-select system storage if exists in playground 2026-02-01 18:30:50 +03:00
Rostislav Dugin
744e51d1e1 REFACTOR (email): Refactor commit adding date headers to emails 2026-02-01 16:43:53 +03:00
Rostislav Dugin
b3af75d430 Merge branch 'develop' of https://github.com/databasus/databasus into develop 2026-02-01 16:41:52 +03:00
mcarbs
6f7320abeb FIX (email): Add email date header 2026-02-01 16:41:17 +03:00
Rostislav Dugin
a1655d35a6 FIX (healthcheck): Add cache accessibility to healthcheck 2026-01-30 16:33:39 +03:00
Rostislav Dugin
9b6e801184 Merge pull request #316 from databasus/develop
FEATURE (email): Add sending email about members invitation and passw…
2026-01-28 17:29:58 +03:00
Rostislav Dugin
105777ab6f FEATURE (email): Add sending email about members invitation and password reset 2026-01-28 17:28:36 +03:00
Rostislav Dugin
3a1a88d5cf Merge pull request #315 from databasus/develop
FIX (env): Fix env detection over startup
2026-01-28 11:33:06 +03:00
Rostislav Dugin
699ca16814 FIX (env): Fix env detection over startup 2026-01-28 11:32:19 +03:00
71 changed files with 2796 additions and 305 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,4 @@
ansible/
postgresus_data/
postgresus-data/
databasus-data/
@@ -9,4 +10,5 @@ node_modules/
/articles
.DS_Store
/scripts
/scripts
.vscode/settings.json

256
AGENTS.md
View File

@@ -1,35 +1,37 @@
# Agent Rules and Guidelines
This document contains all coding standards, conventions and best practices recommended for the Databasus project.
This document contains all coding standards, conventions and best practices recommended for the TgTaps project.
This is NOT a strict set of rules, but a set of recommendations to help you write better code.
---
## Table of Contents
- [Engineering Philosophy](#engineering-philosophy)
- [Backend Guidelines](#backend-guidelines)
- [Code Style](#code-style)
- [Engineering philosophy](#engineering-philosophy)
- [Backend guidelines](#backend-guidelines)
- [Code style](#code-style)
- [Boolean naming](#boolean-naming)
- [Add reasonable new lines between logical statements](#add-reasonable-new-lines-between-logical-statements)
- [Comments](#comments)
- [Controllers](#controllers)
- [Dependency Injection (DI)](#dependency-injection-di)
- [Dependency injection (DI)](#dependency-injection-di)
- [Migrations](#migrations)
- [Refactoring](#refactoring)
- [Testing](#testing)
- [Time Handling](#time-handling)
- [CRUD Examples](#crud-examples)
- [Frontend Guidelines](#frontend-guidelines)
- [React Component Structure](#react-component-structure)
- [Time handling](#time-handling)
- [CRUD examples](#crud-examples)
- [Frontend guidelines](#frontend-guidelines)
- [React component structure](#react-component-structure)
---
## Engineering Philosophy
## Engineering philosophy
**Think like a skeptical senior engineer and code reviewer. Don't just do what was asked—also think about what should have been asked.**
⚠️ **Balance vigilance with pragmatism:** Catch real issues, not theoretical ones. Don't let perfect be the enemy of good.
### Task Context Assessment:
### Task context assessment:
**First, assess the task scope:**
@@ -38,7 +40,7 @@ This is NOT a strict set of rules, but a set of recommendations to help you writ
- **Complex** (architecture, security, performance-critical): Full analysis required
- **Unclear** (ambiguous requirements): Always clarify assumptions first
### For Non-Trivial Tasks:
### For non-trivial tasks:
1. **Restate the objective and list assumptions** (explicit + implicit)
- If any assumption is shaky, call it out clearly
@@ -71,7 +73,7 @@ This is NOT a strict set of rules, but a set of recommendations to help you writ
- Patch the answer accordingly
- Verify edge cases are handled
### Application Guidelines:
### Application guidelines:
**Scale your response to the task:**
@@ -84,9 +86,9 @@ This is NOT a strict set of rules, but a set of recommendations to help you writ
---
## Backend Guidelines
## Backend guidelines
### Code Style
### Code style
**Always place private methods to the bottom of file**
@@ -94,7 +96,7 @@ This rule applies to ALL Go files including tests, services, controllers, reposi
In Go, exported (public) functions/methods start with uppercase letters, while unexported (private) ones start with lowercase letters.
#### Structure Order:
#### Structure order:
1. Type definitions and constants
2. Public methods/functions (uppercase)
@@ -227,7 +229,7 @@ func (c *ProjectController) extractProjectID(ctx *gin.Context) uuid.UUID {
}
```
#### Key Points:
#### Key points:
- **Exported/Public** = starts with uppercase letter (CreateUser, GetProject)
- **Unexported/Private** = starts with lowercase letter (validateUser, handleError)
@@ -237,13 +239,13 @@ func (c *ProjectController) extractProjectID(ctx *gin.Context) uuid.UUID {
---
### Boolean Naming
### Boolean naming
**Always prefix boolean variables with verbs like `is`, `has`, `was`, `should`, `can`, etc.**
This makes the code more readable and clearly indicates that the variable represents a true/false state.
#### Good Examples:
#### Good examples:
```go
type User struct {
@@ -265,7 +267,7 @@ wasCompleted := false
hasPermission := checkPermissions()
```
#### Bad Examples:
#### Bad examples:
```go
type User struct {
@@ -286,7 +288,7 @@ completed := false // Should be: wasCompleted
permission := true // Should be: hasPermission
```
#### Common Boolean Prefixes:
#### Common boolean prefixes:
- **is** - current state (IsActive, IsValid, IsEnabled)
- **has** - possession or presence (HasAccess, HasPermission, HasError)
@@ -297,6 +299,167 @@ permission := true // Should be: hasPermission
---
### Add reasonable new lines between logical statements
**Add blank lines between logical blocks to improve code readability.**
Separate different logical operations within a function with blank lines. This makes the code flow clearer and helps identify distinct steps in the logic.
#### Guidelines:
- Add blank line before final `return` statement
- Add blank line after variable declarations before using them
- Add blank line between error handling and subsequent logic
- Add blank line between different logical operations
#### Bad example (without spacing):
```go
func (t *Task) BeforeSave(tx *gorm.DB) error {
if len(t.Messages) > 0 {
messagesBytes, err := json.Marshal(t.Messages)
if err != nil {
return err
}
t.MessagesJSON = string(messagesBytes)
}
return nil
}
func (t *Task) AfterFind(tx *gorm.DB) error {
if t.MessagesJSON != "" {
var messages []onewin_dto.TaskCompletionMessage
if err := json.Unmarshal([]byte(t.MessagesJSON), &messages); err != nil {
return err
}
t.Messages = messages
}
return nil
}
```
#### Good example (with proper spacing):
```go
func (t *Task) BeforeSave(tx *gorm.DB) error {
if len(t.Messages) > 0 {
messagesBytes, err := json.Marshal(t.Messages)
if err != nil {
return err
}
t.MessagesJSON = string(messagesBytes)
}
return nil
}
func (t *Task) AfterFind(tx *gorm.DB) error {
if t.MessagesJSON != "" {
var messages []onewin_dto.TaskCompletionMessage
if err := json.Unmarshal([]byte(t.MessagesJSON), &messages); err != nil {
return err
}
t.Messages = messages
}
return nil
}
```
#### More examples:
**Service method with multiple operations:**
```go
func (s *UserService) CreateUser(request *CreateUserRequest) (*User, error) {
// Validate input
if err := s.validateUserRequest(request); err != nil {
return nil, err
}
// Create user entity
user := &User{
ID: uuid.New(),
Name: request.Name,
Email: request.Email,
}
// Save to database
if err := s.repository.Create(user); err != nil {
return nil, err
}
// Send notification
s.notificationService.SendWelcomeEmail(user.Email)
return user, nil
}
```
**Repository method with query building:**
```go
func (r *Repository) GetFiltered(filters *Filters) ([]*Entity, error) {
query := storage.GetDb().Model(&Entity{})
if filters.Status != "" {
query = query.Where("status = ?", filters.Status)
}
if filters.CreatedAfter != nil {
query = query.Where("created_at > ?", filters.CreatedAfter)
}
var entities []*Entity
if err := query.Find(&entities).Error; err != nil {
return nil, err
}
return entities, nil
}
```
**Repository method with error handling:**
Bad (without spacing):
```go
func (r *Repository) FindById(id uuid.UUID) (*models.Task, error) {
var task models.Task
result := storage.GetDb().Where("id = ?", id).First(&task)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, errors.New("task not found")
}
return nil, result.Error
}
return &task, nil
}
```
Good (with proper spacing):
```go
func (r *Repository) FindById(id uuid.UUID) (*models.Task, error) {
var task models.Task
result := storage.GetDb().Where("id = ?", id).First(&task)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, errors.New("task not found")
}
return nil, result.Error
}
return &task, nil
}
```
---
### Comments
#### Guidelines
@@ -305,13 +468,14 @@ permission := true // Should be: hasPermission
2. **Functions and variables should have meaningful names** - Code should be self-documenting
3. **Comments for unclear code only** - Only add comments when code logic isn't immediately clear
#### Key Principles:
#### Key principles:
- **Code should tell a story** - Use descriptive variable and function names
- **Comments explain WHY, not WHAT** - The code shows what happens, comments explain business logic or complex decisions
- **Prefer refactoring over commenting** - If code needs explaining, consider making it clearer instead
- **API documentation is required** - Swagger comments for all HTTP endpoints are mandatory
- **Complex algorithms deserve comments** - Mathematical formulas, business rules, or non-obvious optimizations
- **Do not write summary sections in .md files unless directly requested** - Avoid adding "Summary" or "Conclusion" sections at the end of documentation files unless the user explicitly asks for them
#### Example of useless comments:
@@ -343,7 +507,7 @@ func CreateValidLogItems(count int, uniqueID string) []logs_receiving.LogItemReq
### Controllers
#### Controller Guidelines:
#### Controller guidelines:
1. **When we write controller:**
- We combine all routes to single controller
@@ -475,7 +639,7 @@ func (c *AuditLogController) GetUserAuditLogs(ctx *gin.Context) {
---
### Dependency Injection (DI)
### Dependency injection (DI)
For DI files use **implicit fields declaration styles** (especially for controllers, services, repositories, use cases, etc., not simple data structures).
@@ -503,7 +667,7 @@ var orderController = &OrderController{
**This is needed to avoid forgetting to update DI style when we add new dependency.**
#### Force Such Usage
#### Force such usage
Please force such usage if file look like this (see some services\controllers\repos definitions and getters):
@@ -549,13 +713,13 @@ func GetOrderRepository() *repositories.OrderRepository {
}
```
#### SetupDependencies() Pattern
#### SetupDependencies() pattern
**All `SetupDependencies()` functions must use sync.Once to ensure idempotent execution.**
This pattern allows `SetupDependencies()` to be safely called multiple times (especially in tests) while ensuring the actual setup logic executes only once.
**Implementation Pattern:**
**Implementation pattern:**
```go
package feature
@@ -588,7 +752,7 @@ func SetupDependencies() {
}
```
**Why This Pattern:**
**Why this pattern:**
- **Tests can call multiple times**: Test setup often calls `SetupDependencies()` multiple times without issues
- **Thread-safe**: Works correctly with concurrent calls (nanoseconds or seconds apart)
@@ -604,13 +768,13 @@ func SetupDependencies() {
---
### Background Services
### Background services
**All background service `Run()` methods must panic if called multiple times to prevent corrupted states.**
Background services run infinite loops and must never be started twice on the same instance. Multiple calls indicate a serious bug that would cause duplicate goroutines, resource leaks, and data corruption.
**Implementation Pattern:**
**Implementation pattern:**
```go
package feature
@@ -654,14 +818,14 @@ func (s *BackgroundService) Run(ctx context.Context) {
}
```
**Why Panic Instead of Warning:**
**Why panic instead of warning:**
- **Prevents corruption**: Multiple `Run()` calls would create duplicate goroutines consuming resources
- **Fails fast**: Catches critical bugs immediately in tests and production
- **Clear indication**: Panic clearly indicates a serious programming error
- **Applies everywhere**: Same protection in tests and production
**When This Applies:**
**When this applies:**
- All background services with infinite loops
- Registry services (BackupNodesRegistry, RestoreNodesRegistry)
@@ -727,14 +891,14 @@ You can shortify, make more readable, improve code quality, etc. Common logic ca
**After writing tests, always launch them and verify that they pass.**
#### Test Naming Format
#### Test naming format
Use these naming patterns:
- `Test_WhatWeDo_WhatWeExpect`
- `Test_WhatWeDo_WhichConditions_WhatWeExpect`
#### Examples from Real Codebase:
#### Examples from real codebase:
- `Test_CreateApiKey_WhenUserIsProjectOwner_ApiKeyCreated`
- `Test_UpdateProject_WhenUserIsProjectAdmin_ProjectUpdated`
@@ -742,22 +906,22 @@ Use these naming patterns:
- `Test_GetProjectAuditLogs_WithDifferentUserRoles_EnforcesPermissionsCorrectly`
- `Test_ProjectLifecycleE2E_CompletesSuccessfully`
#### Testing Philosophy
#### Testing philosophy
**Prefer Controllers Over Unit Tests:**
**Prefer controllers over unit tests:**
- Test through HTTP endpoints via controllers whenever possible
- Avoid testing repositories, services in isolation - test via API instead
- Only use unit tests for complex model logic when no API exists
- Name test files `controller_test.go` or `service_test.go`, not `integration_test.go`
**Extract Common Logic to Testing Utilities:**
**Extract common logic to testing utilities:**
- Create `testing.go` or `testing/testing.go` files for shared test utilities
- Extract router creation, user setup, models creation helpers (in API, not just structs creation)
- Reuse common patterns across different test files
**Refactor Existing Tests:**
**Refactor existing tests:**
- When working with existing tests, always look for opportunities to refactor and improve
- Extract repetitive setup code to common utilities
@@ -766,7 +930,7 @@ Use these naming patterns:
- Consolidate similar test patterns across different test files
- Make tests more readable and maintainable for other developers
**Clean Up Test Data:**
**Clean up test data:**
- If the feature supports cleanup operations (DELETE endpoints, cleanup methods), use them in tests
- Clean up resources after test execution to avoid test data pollution
@@ -803,7 +967,7 @@ func Test_BackupLifecycle_CreateAndDelete(t *testing.T) {
}
```
#### Testing Utilities Structure
#### Testing utilities structure
**Create `testing.go` or `testing/testing.go` files with common utilities:**
@@ -839,7 +1003,7 @@ func AddMemberToProject(project *projects_models.Project, member *users_dto.Sign
}
```
#### Controller Test Examples
#### Controller test examples
**Permission-based testing:**
@@ -906,7 +1070,7 @@ func Test_ProjectLifecycleE2E_CompletesSuccessfully(t *testing.T) {
---
### Time Handling
### Time handling
**Always use `time.Now().UTC()` instead of `time.Now()`**
@@ -914,7 +1078,7 @@ This ensures consistent timezone handling across the application.
---
### CRUD Examples
### CRUD examples
This is an example of complete CRUD implementation structure:
@@ -1578,9 +1742,9 @@ func createTimedLog(db *gorm.DB, userID *uuid.UUID, message string, createdAt ti
---
## Frontend Guidelines
## Frontend guidelines
### React Component Structure
### React component structure
Write React components with the following structure:
@@ -1614,7 +1778,7 @@ export const ReactComponent = ({ someValue }: Props): JSX.Element => {
}
```
#### Structure Order:
#### Structure order:
1. **Props interface** - Define component props
2. **Helper functions** (outside component) - Pure utility functions

View File

@@ -253,16 +253,34 @@ PG_BIN="/usr/lib/postgresql/17/bin"
# Generate runtime configuration for frontend
echo "Generating runtime configuration..."
cat > /app/ui/build/runtime-config.js << 'JSEOF'
# Detect if email is configured (both SMTP_HOST and DATABASUS_URL must be set)
if [ -n "\${SMTP_HOST:-}" ] && [ -n "\${DATABASUS_URL:-}" ]; then
IS_EMAIL_CONFIGURED="true"
else
IS_EMAIL_CONFIGURED="false"
fi
cat > /app/ui/build/runtime-config.js <<JSEOF
// Runtime configuration injected at container startup
// This file is generated dynamically and should not be edited manually
window.__RUNTIME_CONFIG__ = {
IS_CLOUD: '\${IS_CLOUD:-false}',
GITHUB_CLIENT_ID: '\${GITHUB_CLIENT_ID:-}',
GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}'
GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}',
IS_EMAIL_CONFIGURED: '\$IS_EMAIL_CONFIGURED'
};
JSEOF
# Inject analytics script if provided (only if not already injected)
if [ -n "\${ANALYTICS_SCRIPT:-}" ]; then
if ! grep -q "rybbit.databasus.com" /app/ui/build/index.html 2>/dev/null; then
echo "Injecting analytics script..."
sed -i "s#</head># \${ANALYTICS_SCRIPT}\\
</head>#" /app/ui/build/index.html
fi
fi
# Ensure proper ownership of data directory
echo "Setting up data directory permissions..."
mkdir -p /databasus-data/pgdata
@@ -413,6 +431,8 @@ fi
exec ./main
EOF
LABEL org.opencontainers.image.source="https://github.com/databasus/databasus"
RUN chmod +x /app/start.sh
EXPOSE 4005
@@ -421,4 +441,4 @@ EXPOSE 4005
VOLUME ["/databasus-data"]
ENTRYPOINT ["/app/start.sh"]
CMD []
CMD []

View File

@@ -114,6 +114,15 @@ type EnvVariables struct {
TestSupabaseUsername string `env:"TEST_SUPABASE_USERNAME"`
TestSupabasePassword string `env:"TEST_SUPABASE_PASSWORD"`
TestSupabaseDatabase string `env:"TEST_SUPABASE_DATABASE"`
// SMTP configuration (optional)
SMTPHost string `env:"SMTP_HOST"`
SMTPPort int `env:"SMTP_PORT"`
SMTPUser string `env:"SMTP_USER"`
SMTPPassword string `env:"SMTP_PASSWORD"`
// Application URL (optional) - used for email links
DatabasusURL string `env:"DATABASUS_URL"`
}
var (

View File

@@ -103,6 +103,16 @@ func (s *BackupsScheduler) IsSchedulerRunning() bool {
return s.lastBackupTime.After(time.Now().UTC().Add(-schedulerHealthcheckThreshold))
}
func (s *BackupsScheduler) IsBackupNodesAvailable() bool {
nodes, err := s.backupNodesRegistry.GetAvailableNodes()
if err != nil {
s.logger.Error("Failed to get available nodes for health check", "error", err)
return false
}
return len(nodes) > 0
}
func (s *BackupsScheduler) StartBackup(databaseID uuid.UUID, isCallNotifier bool) {
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(databaseID)
if err != nil {

View File

@@ -1366,11 +1366,24 @@ func createTestBackup(
panic(err)
}
storages, err := storages.GetStorageService().GetStorages(user, *database.WorkspaceID)
if err != nil || len(storages) == 0 {
loadedStorages, err := storages.GetStorageService().GetStorages(user, *database.WorkspaceID)
if err != nil || len(loadedStorages) == 0 {
panic("No storage found for workspace")
}
// Filter out system storages
var nonSystemStorages []*storages.Storage
for _, storage := range loadedStorages {
if !storage.IsSystem {
nonSystemStorages = append(nonSystemStorages, storage)
}
}
if len(nonSystemStorages) == 0 {
panic("No non-system storage found for workspace")
}
storages := nonSystemStorages
backup := &backups_core.Backup{
ID: uuid.New(),
DatabaseID: database.ID,

View File

@@ -108,6 +108,7 @@ func (uc *CreateMariadbBackupUsecase) buildMariadbDumpArgs(
"--single-transaction",
"--routines",
"--quick",
"--skip-extended-insert",
"--verbose",
}

View File

@@ -107,6 +107,7 @@ func (uc *CreateMysqlBackupUsecase) buildMysqldumpArgs(my *mysqltypes.MysqlDatab
"--routines",
"--set-gtid-purged=OFF",
"--quick",
"--skip-extended-insert",
"--verbose",
}

View File

@@ -1164,12 +1164,13 @@ func getTestMongodbConfig() *mongodb.MongodbDatabase {
return &mongodb.MongodbDatabase{
Version: tools.MongodbVersion7,
Host: config.GetEnv().TestLocalhost,
Port: port,
Port: &port,
Username: "root",
Password: "rootpassword",
Database: "testdb",
AuthDatabase: "admin",
IsHttps: false,
IsSrv: false,
CpuCount: 1,
}
}

View File

@@ -26,12 +26,13 @@ type MongodbDatabase struct {
Version tools.MongodbVersion `json:"version" gorm:"type:text;not null"`
Host string `json:"host" gorm:"type:text;not null"`
Port int `json:"port" gorm:"type:int;not null"`
Port *int `json:"port" gorm:"type:int"`
Username string `json:"username" gorm:"type:text;not null"`
Password string `json:"password" gorm:"type:text;not null"`
Database string `json:"database" gorm:"type:text;not null"`
AuthDatabase string `json:"authDatabase" gorm:"type:text;not null;default:'admin'"`
IsHttps bool `json:"isHttps" gorm:"type:boolean;default:false"`
IsSrv bool `json:"isSrv" gorm:"column:is_srv;type:boolean;not null;default:false"`
CpuCount int `json:"cpuCount" gorm:"column:cpu_count;type:int;not null;default:1"`
}
@@ -43,9 +44,13 @@ func (m *MongodbDatabase) Validate() error {
if m.Host == "" {
return errors.New("host is required")
}
if m.Port == 0 {
return errors.New("port is required")
if !m.IsSrv {
if m.Port == nil || *m.Port == 0 {
return errors.New("port is required for standard connections")
}
}
if m.Username == "" {
return errors.New("username is required")
}
@@ -58,6 +63,7 @@ func (m *MongodbDatabase) Validate() error {
if m.CpuCount <= 0 {
return errors.New("cpu count must be greater than 0")
}
return nil
}
@@ -125,6 +131,7 @@ func (m *MongodbDatabase) Update(incoming *MongodbDatabase) {
m.Database = incoming.Database
m.AuthDatabase = incoming.AuthDatabase
m.IsHttps = incoming.IsHttps
m.IsSrv = incoming.IsSrv
m.CpuCount = incoming.CpuCount
if incoming.Password != "" {
@@ -455,12 +462,29 @@ func (m *MongodbDatabase) buildConnectionURI(password string) string {
tlsParams = "&tls=true&tlsInsecure=true"
}
if m.IsSrv {
return fmt.Sprintf(
"mongodb+srv://%s:%s@%s/%s?authSource=%s&connectTimeoutMS=15000%s",
url.QueryEscape(m.Username),
url.QueryEscape(password),
m.Host,
m.Database,
authDB,
tlsParams,
)
}
port := 27017
if m.Port != nil {
port = *m.Port
}
return fmt.Sprintf(
"mongodb://%s:%s@%s:%d/%s?authSource=%s&connectTimeoutMS=15000%s",
url.QueryEscape(m.Username),
url.QueryEscape(password),
m.Host,
m.Port,
port,
m.Database,
authDB,
tlsParams,
@@ -479,12 +503,28 @@ func (m *MongodbDatabase) BuildMongodumpURI(password string) string {
tlsParams = "&tls=true&tlsInsecure=true"
}
if m.IsSrv {
return fmt.Sprintf(
"mongodb+srv://%s:%s@%s/?authSource=%s&connectTimeoutMS=15000%s",
url.QueryEscape(m.Username),
url.QueryEscape(password),
m.Host,
authDB,
tlsParams,
)
}
port := 27017
if m.Port != nil {
port = *m.Port
}
return fmt.Sprintf(
"mongodb://%s:%s@%s:%d/?authSource=%s&connectTimeoutMS=15000%s",
url.QueryEscape(m.Username),
url.QueryEscape(password),
m.Host,
m.Port,
port,
authDB,
tlsParams,
)

View File

@@ -64,15 +64,17 @@ func Test_TestConnection_InsufficientPermissions_ReturnsError(t *testing.T) {
defer dropUserSafe(container.Client, limitedUsername, container.AuthDatabase)
port := container.Port
mongodbModel := &MongodbDatabase{
Version: tc.version,
Host: container.Host,
Port: container.Port,
Port: &port,
Username: limitedUsername,
Password: limitedPassword,
Database: container.Database,
AuthDatabase: container.AuthDatabase,
IsHttps: false,
IsSrv: false,
CpuCount: 1,
}
@@ -133,15 +135,17 @@ func Test_TestConnection_SufficientPermissions_Success(t *testing.T) {
defer dropUserSafe(container.Client, backupUsername, container.AuthDatabase)
port := container.Port
mongodbModel := &MongodbDatabase{
Version: tc.version,
Host: container.Host,
Port: container.Port,
Port: &port,
Username: backupUsername,
Password: backupPassword,
Database: container.Database,
AuthDatabase: container.AuthDatabase,
IsHttps: false,
IsSrv: false,
CpuCount: 1,
}
@@ -442,15 +446,17 @@ func connectToMongodbContainer(
}
func createMongodbModel(container *MongodbContainer) *MongodbDatabase {
port := container.Port
return &MongodbDatabase{
Version: container.Version,
Host: container.Host,
Port: container.Port,
Port: &port,
Username: container.Username,
Password: container.Password,
Database: container.Database,
AuthDatabase: container.AuthDatabase,
IsHttps: false,
IsSrv: false,
CpuCount: 1,
}
}
@@ -489,3 +495,157 @@ func assertWriteDenied(t *testing.T, err error) {
strings.Contains(errStr, "permission denied"),
"Expected authorization error, got: %v", err)
}
func Test_BuildConnectionURI_WithSrvFormat_ReturnsCorrectUri(t *testing.T) {
port := 27017
model := &MongodbDatabase{
Host: "cluster0.example.mongodb.net",
Port: &port,
Username: "testuser",
Password: "testpass123",
Database: "mydb",
AuthDatabase: "admin",
IsHttps: false,
IsSrv: true,
}
uri := model.buildConnectionURI("testpass123")
assert.Contains(t, uri, "mongodb+srv://")
assert.Contains(t, uri, "testuser")
assert.Contains(t, uri, "testpass123")
assert.Contains(t, uri, "cluster0.example.mongodb.net")
assert.Contains(t, uri, "/mydb")
assert.Contains(t, uri, "authSource=admin")
assert.Contains(t, uri, "connectTimeoutMS=15000")
assert.NotContains(t, uri, ":27017")
}
func Test_BuildConnectionURI_WithStandardFormat_ReturnsCorrectUri(t *testing.T) {
port := 27017
model := &MongodbDatabase{
Host: "localhost",
Port: &port,
Username: "testuser",
Password: "testpass123",
Database: "mydb",
AuthDatabase: "admin",
IsHttps: false,
IsSrv: false,
}
uri := model.buildConnectionURI("testpass123")
assert.Contains(t, uri, "mongodb://")
assert.Contains(t, uri, "testuser")
assert.Contains(t, uri, "testpass123")
assert.Contains(t, uri, "localhost:27017")
assert.Contains(t, uri, "/mydb")
assert.Contains(t, uri, "authSource=admin")
assert.Contains(t, uri, "connectTimeoutMS=15000")
assert.NotContains(t, uri, "mongodb+srv://")
}
func Test_BuildConnectionURI_WithNullPort_UsesDefault(t *testing.T) {
model := &MongodbDatabase{
Host: "localhost",
Port: nil,
Username: "testuser",
Password: "testpass123",
Database: "mydb",
AuthDatabase: "admin",
IsHttps: false,
IsSrv: false,
}
uri := model.buildConnectionURI("testpass123")
assert.Contains(t, uri, "localhost:27017")
}
func Test_BuildMongodumpURI_WithSrvFormat_ReturnsCorrectUri(t *testing.T) {
port := 27017
model := &MongodbDatabase{
Host: "cluster0.example.mongodb.net",
Port: &port,
Username: "testuser",
Password: "testpass123",
Database: "mydb",
AuthDatabase: "admin",
IsHttps: false,
IsSrv: true,
}
uri := model.BuildMongodumpURI("testpass123")
assert.Contains(t, uri, "mongodb+srv://")
assert.Contains(t, uri, "testuser")
assert.Contains(t, uri, "testpass123")
assert.Contains(t, uri, "cluster0.example.mongodb.net")
assert.Contains(t, uri, "/?authSource=admin")
assert.Contains(t, uri, "connectTimeoutMS=15000")
assert.NotContains(t, uri, ":27017")
assert.NotContains(t, uri, "/mydb")
}
func Test_BuildMongodumpURI_WithStandardFormat_ReturnsCorrectUri(t *testing.T) {
port := 27017
model := &MongodbDatabase{
Host: "localhost",
Port: &port,
Username: "testuser",
Password: "testpass123",
Database: "mydb",
AuthDatabase: "admin",
IsHttps: false,
IsSrv: false,
}
uri := model.BuildMongodumpURI("testpass123")
assert.Contains(t, uri, "mongodb://")
assert.Contains(t, uri, "testuser")
assert.Contains(t, uri, "testpass123")
assert.Contains(t, uri, "localhost:27017")
assert.Contains(t, uri, "/?authSource=admin")
assert.Contains(t, uri, "connectTimeoutMS=15000")
assert.NotContains(t, uri, "mongodb+srv://")
assert.NotContains(t, uri, "/mydb")
}
func Test_Validate_SrvConnection_AllowsNullPort(t *testing.T) {
model := &MongodbDatabase{
Host: "cluster0.example.mongodb.net",
Port: nil,
Username: "testuser",
Password: "testpass123",
Database: "mydb",
AuthDatabase: "admin",
IsHttps: false,
IsSrv: true,
CpuCount: 1,
}
err := model.Validate()
assert.NoError(t, err)
}
func Test_Validate_StandardConnection_RequiresPort(t *testing.T) {
model := &MongodbDatabase{
Host: "localhost",
Port: nil,
Username: "testuser",
Password: "testpass123",
Database: "mydb",
AuthDatabase: "admin",
IsHttps: false,
IsSrv: false,
CpuCount: 1,
}
err := model.Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "port is required for standard connections")
}

View File

@@ -71,12 +71,13 @@ func GetTestMongodbConfig() *mongodb.MongodbDatabase {
return &mongodb.MongodbDatabase{
Version: tools.MongodbVersion7,
Host: config.GetEnv().TestLocalhost,
Port: port,
Port: &port,
Username: "root",
Password: "rootpassword",
Database: "testdb",
AuthDatabase: "admin",
IsHttps: false,
IsSrv: false,
CpuCount: 1,
}
}

View File

@@ -0,0 +1,22 @@
package email
import (
"databasus-backend/internal/config"
"databasus-backend/internal/util/logger"
)
var env = config.GetEnv()
var log = logger.GetLogger()
var emailSMTPSender = &EmailSMTPSender{
log,
env.SMTPHost,
env.SMTPPort,
env.SMTPUser,
env.SMTPPassword,
env.SMTPHost != "" && env.SMTPPort != 0,
}
func GetEmailSMTPSender() *EmailSMTPSender {
return emailSMTPSender
}

View File

@@ -0,0 +1,245 @@
package email
import (
"crypto/tls"
"fmt"
"log/slog"
"mime"
"net"
"net/smtp"
"time"
)
const (
ImplicitTLSPort = 465
DefaultTimeout = 5 * time.Second
DefaultHelloName = "localhost"
MIMETypeHTML = "text/html"
MIMECharsetUTF8 = "UTF-8"
)
type EmailSMTPSender struct {
logger *slog.Logger
smtpHost string
smtpPort int
smtpUser string
smtpPassword string
isConfigured bool
}
func (s *EmailSMTPSender) SendEmail(to, subject, body string) error {
if !s.isConfigured {
s.logger.Warn("Skipping email send, SMTP not initialized", "to", to, "subject", subject)
return nil
}
from := s.smtpUser
if from == "" {
from = "noreply@" + s.smtpHost
}
emailContent := s.buildEmailContent(to, subject, body, from)
isAuthRequired := s.smtpUser != "" && s.smtpPassword != ""
if s.smtpPort == ImplicitTLSPort {
return s.sendImplicitTLS(to, from, emailContent, isAuthRequired)
}
return s.sendStartTLS(to, from, emailContent, isAuthRequired)
}
func (s *EmailSMTPSender) buildEmailContent(to, subject, body, from string) []byte {
// Encode Subject header using RFC 2047 to avoid SMTPUTF8 requirement
encodedSubject := encodeRFC2047(subject)
subjectHeader := fmt.Sprintf("Subject: %s\r\n", encodedSubject)
dateHeader := fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
mimeHeaders := fmt.Sprintf(
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
MIMETypeHTML,
MIMECharsetUTF8,
)
// Encode From header display name if it contains non-ASCII
encodedFrom := encodeRFC2047(from)
fromHeader := fmt.Sprintf("From: %s\r\n", encodedFrom)
toHeader := fmt.Sprintf("To: %s\r\n", to)
return []byte(fromHeader + toHeader + subjectHeader + dateHeader + mimeHeaders + body)
}
func (s *EmailSMTPSender) sendImplicitTLS(
to, from string,
emailContent []byte,
isAuthRequired bool,
) error {
createClient := func() (*smtp.Client, func(), error) {
return s.createImplicitTLSClient()
}
client, cleanup, err := s.authenticateWithRetry(createClient, isAuthRequired)
if err != nil {
return err
}
defer cleanup()
return s.sendEmail(client, to, from, emailContent)
}
func (s *EmailSMTPSender) sendStartTLS(
to, from string,
emailContent []byte,
isAuthRequired bool,
) error {
createClient := func() (*smtp.Client, func(), error) {
return s.createStartTLSClient()
}
client, cleanup, err := s.authenticateWithRetry(createClient, isAuthRequired)
if err != nil {
return err
}
defer cleanup()
return s.sendEmail(client, to, from, emailContent)
}
func (s *EmailSMTPSender) createImplicitTLSClient() (*smtp.Client, func(), error) {
addr := net.JoinHostPort(s.smtpHost, fmt.Sprintf("%d", s.smtpPort))
tlsConfig := &tls.Config{ServerName: s.smtpHost}
dialer := &net.Dialer{Timeout: DefaultTimeout}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, s.smtpHost)
if err != nil {
_ = conn.Close()
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
return client, func() { _ = client.Quit() }, nil
}
func (s *EmailSMTPSender) createStartTLSClient() (*smtp.Client, func(), error) {
addr := net.JoinHostPort(s.smtpHost, fmt.Sprintf("%d", s.smtpPort))
dialer := &net.Dialer{Timeout: DefaultTimeout}
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, s.smtpHost)
if err != nil {
_ = conn.Close()
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := client.Hello(DefaultHelloName); err != nil {
_ = client.Quit()
_ = conn.Close()
return nil, nil, fmt.Errorf("SMTP hello failed: %w", err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: s.smtpHost}); err != nil {
_ = client.Quit()
_ = conn.Close()
return nil, nil, fmt.Errorf("STARTTLS failed: %w", err)
}
}
return client, func() { _ = client.Quit() }, nil
}
func (s *EmailSMTPSender) authenticateWithRetry(
createClient func() (*smtp.Client, func(), error),
isAuthRequired bool,
) (*smtp.Client, func(), error) {
client, cleanup, err := createClient()
if err != nil {
return nil, nil, err
}
if !isAuthRequired {
return client, cleanup, nil
}
// Try PLAIN auth first
plainAuth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
if err := client.Auth(plainAuth); err == nil {
return client, cleanup, nil
}
// PLAIN auth failed, connection may be closed - recreate and try LOGIN auth
cleanup()
client, cleanup, err = createClient()
if err != nil {
return nil, nil, err
}
loginAuth := &loginAuth{username: s.smtpUser, password: s.smtpPassword}
if err := client.Auth(loginAuth); err != nil {
cleanup()
return nil, nil, fmt.Errorf("SMTP authentication failed: %w", err)
}
return client, cleanup, nil
}
func (s *EmailSMTPSender) sendEmail(client *smtp.Client, to, from string, content []byte) error {
if err := client.Mail(from); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
if _, err = writer.Write(content); err != nil {
return fmt.Errorf("failed to write email content: %w", err)
}
if err = writer.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil
}
func encodeRFC2047(s string) string {
return mime.QEncoding.Encode("UTF-8", s)
}
type loginAuth struct {
username string
password string
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:", "User Name\x00":
return []byte(a.username), nil
case "Password:", "Password\x00":
return []byte(a.password), nil
default:
return []byte(a.username), nil
}
}
return nil, nil
}

View File

@@ -130,6 +130,7 @@ func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte
// This ensures compatibility with SMTP servers that don't support SMTPUTF8
encodedSubject := encodeRFC2047(heading)
subject := fmt.Sprintf("Subject: %s\r\n", encodedSubject)
dateHeader := fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
mimeHeaders := fmt.Sprintf(
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
@@ -143,7 +144,7 @@ func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte
toHeader := fmt.Sprintf("To: %s\r\n", e.TargetEmail)
return []byte(fromHeader + toHeader + subject + mimeHeaders + message)
return []byte(fromHeader + toHeader + subject + dateHeader + mimeHeaders + message)
}
func (e *EmailNotifier) sendImplicitTLS(

View File

@@ -32,7 +32,6 @@ import (
tasks_cancellation "databasus-backend/internal/features/tasks/cancellation"
users_dto "databasus-backend/internal/features/users/dto"
users_enums "databasus-backend/internal/features/users/enums"
users_services "databasus-backend/internal/features/users/services"
users_testing "databasus-backend/internal/features/users/testing"
workspaces_models "databasus-backend/internal/features/workspaces/models"
workspaces_testing "databasus-backend/internal/features/workspaces/testing"
@@ -358,7 +357,7 @@ func Test_RestoreBackup_DiskSpaceValidation(t *testing.T) {
_, err = configService.SaveBackupConfig(config)
assert.NoError(t, err)
backup = createTestBackup(mysqlDB, owner)
backup = createTestBackup(mysqlDB, storage)
request = restores_core.RestoreBackupRequest{
MysqlDatabase: &mysql.MysqlDatabase{
@@ -610,7 +609,7 @@ func createTestDatabaseWithBackupForRestore(
panic(err)
}
backup := createTestBackup(database, owner)
backup := createTestBackup(database, storage)
return database, backup
}
@@ -727,24 +726,14 @@ func createTestStorage(workspaceID uuid.UUID) *storages.Storage {
func createTestBackup(
database *databases.Database,
owner *users_dto.SignInResponseDTO,
storage *storages.Storage,
) *backups_core.Backup {
fieldEncryptor := util_encryption.GetFieldEncryptor()
userService := users_services.GetUserService()
user, err := userService.GetUserFromToken(owner.Token)
if err != nil {
panic(err)
}
storages, err := storages.GetStorageService().GetStorages(user, *database.WorkspaceID)
if err != nil || len(storages) == 0 {
panic("No storage found for workspace")
}
backup := &backups_core.Backup{
ID: uuid.New(),
DatabaseID: database.ID,
StorageID: storages[0].ID,
StorageID: storage.ID,
Status: backups_core.BackupStatusCompleted,
BackupSizeMb: 10.5,
BackupDurationMs: 1000,
@@ -759,7 +748,7 @@ func createTestBackup(
dummyContent := []byte("dummy backup content for testing")
reader := strings.NewReader(string(dummyContent))
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := storages[0].SaveFile(
if err := storage.SaveFile(
context.Background(),
fieldEncryptor,
logger,

View File

@@ -58,7 +58,8 @@ func (c *StorageController) SaveStorage(ctx *gin.Context) {
}
if err := c.storageService.SaveStorage(user, request.WorkspaceID, &request); err != nil {
if errors.Is(err, ErrInsufficientPermissionsToManageStorage) {
if errors.Is(err, ErrInsufficientPermissionsToManageStorage) ||
errors.Is(err, ErrLocalStorageNotAllowedInCloudMode) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -325,7 +326,11 @@ func (c *StorageController) TestStorageConnectionDirect(ctx *gin.Context) {
return
}
if err := c.storageService.TestStorageConnectionDirect(&request); err != nil {
if err := c.storageService.TestStorageConnectionDirect(user, &request); err != nil {
if errors.Is(err, ErrLocalStorageNotAllowedInCloudMode) {
ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

View File

@@ -21,6 +21,7 @@ import (
users_services "databasus-backend/internal/features/users/services"
users_testing "databasus-backend/internal/features/users/testing"
workspaces_controllers "databasus-backend/internal/features/workspaces/controllers"
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
workspaces_testing "databasus-backend/internal/features/workspaces/testing"
"databasus-backend/internal/util/encryption"
test_utils "databasus-backend/internal/util/testing"
@@ -1969,6 +1970,143 @@ func Test_TransferSystemStorage_TransferBlocked(t *testing.T) {
workspaces_testing.RemoveTestWorkspace(workspaceB, router)
}
func Test_DeleteWorkspace_SystemStoragesFromAnotherWorkspaceNotRemovedAndWorkspaceDeletedSuccessfully(
t *testing.T,
) {
router := createRouter()
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
workspaceA := workspaces_testing.CreateTestWorkspace("Workspace A", admin, router)
workspaceD := workspaces_testing.CreateTestWorkspace("Workspace D", admin, router)
// Create a system storage in workspace A
systemStorage := &Storage{
WorkspaceID: workspaceA.ID,
Type: StorageTypeLocal,
Name: "Test System Storage " + uuid.New().String(),
IsSystem: true,
LocalStorage: &local_storage.LocalStorage{},
}
var savedSystemStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+admin.Token,
*systemStorage,
http.StatusOK,
&savedSystemStorage,
)
assert.True(t, savedSystemStorage.IsSystem)
assert.Equal(t, workspaceA.ID, savedSystemStorage.WorkspaceID)
// Create a regular storage in workspace D
regularStorage := createNewStorage(workspaceD.ID)
var savedRegularStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+admin.Token,
*regularStorage,
http.StatusOK,
&savedRegularStorage,
)
assert.False(t, savedRegularStorage.IsSystem)
assert.Equal(t, workspaceD.ID, savedRegularStorage.WorkspaceID)
// Delete workspace D
workspaces_testing.DeleteWorkspace(workspaceD, admin.Token, router)
// Verify system storage from workspace A still exists
repository := &StorageRepository{}
systemStorageAfterDeletion, err := repository.FindByID(savedSystemStorage.ID)
assert.NoError(t, err, "System storage should still exist after workspace D deletion")
assert.NotNil(t, systemStorageAfterDeletion)
assert.Equal(t, savedSystemStorage.ID, systemStorageAfterDeletion.ID)
assert.True(t, systemStorageAfterDeletion.IsSystem)
assert.Equal(t, workspaceA.ID, systemStorageAfterDeletion.WorkspaceID)
// Verify regular storage from workspace D was deleted
regularStorageAfterDeletion, err := repository.FindByID(savedRegularStorage.ID)
assert.Error(t, err, "Regular storage should be deleted with workspace D")
assert.Nil(t, regularStorageAfterDeletion)
// Cleanup
deleteStorage(t, router, savedSystemStorage.ID, admin.Token)
workspaces_testing.RemoveTestWorkspace(workspaceA, router)
}
func Test_DeleteWorkspace_WithOwnSystemStorage_ReturnsForbidden(t *testing.T) {
router := createRouter()
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
workspaceA := workspaces_testing.CreateTestWorkspace("Workspace A", admin, router)
// Create a system storage assigned to workspace A
systemStorage := &Storage{
WorkspaceID: workspaceA.ID,
Type: StorageTypeLocal,
Name: "System Storage in A " + uuid.New().String(),
IsSystem: true,
LocalStorage: &local_storage.LocalStorage{},
}
var savedSystemStorage Storage
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/storages",
"Bearer "+admin.Token,
*systemStorage,
http.StatusOK,
&savedSystemStorage,
)
assert.True(t, savedSystemStorage.IsSystem)
assert.Equal(t, workspaceA.ID, savedSystemStorage.WorkspaceID)
// Attempt to delete workspace A - should fail because it has a system storage
resp := workspaces_testing.MakeAPIRequest(
router,
"DELETE",
"/api/v1/workspaces/"+workspaceA.ID.String(),
"Bearer "+admin.Token,
nil,
)
assert.Equal(t, http.StatusBadRequest, resp.Code, "Workspace deletion should fail")
assert.Contains(
t,
resp.Body.String(),
"system storage cannot be deleted due to workspace deletion",
"Error message should indicate system storage prevents deletion",
)
// Verify workspace still exists
workspaceRepo := &workspaces_repositories.WorkspaceRepository{}
workspaceAfterFailedDeletion, err := workspaceRepo.GetWorkspaceByID(workspaceA.ID)
assert.NoError(t, err, "Workspace should still exist after failed deletion")
assert.NotNil(t, workspaceAfterFailedDeletion)
assert.Equal(t, workspaceA.ID, workspaceAfterFailedDeletion.ID)
// Verify system storage still exists
repository := &StorageRepository{}
storageAfterFailedDeletion, err := repository.FindByID(savedSystemStorage.ID)
assert.NoError(t, err, "System storage should still exist after failed deletion")
assert.NotNil(t, storageAfterFailedDeletion)
assert.Equal(t, savedSystemStorage.ID, storageAfterFailedDeletion.ID)
assert.True(t, storageAfterFailedDeletion.IsSystem)
// Cleanup: Delete system storage first, then workspace can be deleted
deleteStorage(t, router, savedSystemStorage.ID, admin.Token)
workspaces_testing.DeleteWorkspace(workspaceA, admin.Token, router)
// Verify workspace was successfully deleted after storage removal
_, err = workspaceRepo.GetWorkspaceByID(workspaceA.ID)
assert.Error(t, err, "Workspace should be deleted after storage was removed")
}
func createRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -1983,6 +2121,7 @@ func createRouter() *gin.Engine {
}
audit_logs.SetupDependencies()
SetupDependencies()
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
return router

View File

@@ -39,4 +39,7 @@ var (
ErrSystemStorageCannotBeMadePrivate = errors.New(
"system storage cannot be changed to non-system",
)
ErrLocalStorageNotAllowedInCloudMode = errors.New(
"local storage can only be managed by administrators in cloud mode",
)
)

View File

@@ -3,6 +3,7 @@ package storages
import (
"fmt"
"databasus-backend/internal/config"
audit_logs "databasus-backend/internal/features/audit_logs"
users_enums "databasus-backend/internal/features/users/enums"
users_models "databasus-backend/internal/features/users/models"
@@ -24,6 +25,32 @@ func (s *StorageService) SetStorageDatabaseCounter(storageDatabaseCounter Storag
s.storageDatabaseCounter = storageDatabaseCounter
}
func (s *StorageService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error {
storages, err := s.storageRepository.FindByWorkspaceID(workspaceID)
if err != nil {
return fmt.Errorf("failed to get storages for workspace deletion: %w", err)
}
for _, storage := range storages {
if storage.IsSystem && storage.WorkspaceID != workspaceID {
// skip system storage from another workspace
continue
}
if storage.IsSystem && storage.WorkspaceID == workspaceID {
return fmt.Errorf(
"system storage cannot be deleted due to workspace deletion, please transfer or remove storage first",
)
}
if err := s.storageRepository.Delete(storage); err != nil {
return fmt.Errorf("failed to delete storage %s: %w", storage.ID, err)
}
}
return nil
}
func (s *StorageService) SaveStorage(
user *users_models.User,
workspaceID uuid.UUID,
@@ -37,6 +64,11 @@ func (s *StorageService) SaveStorage(
return ErrInsufficientPermissionsToManageStorage
}
if config.GetEnv().IsCloud && storage.Type == StorageTypeLocal &&
user.Role != users_enums.UserRoleAdmin {
return ErrLocalStorageNotAllowedInCloudMode
}
isUpdate := storage.ID != uuid.Nil
if storage.IsSystem && user.Role != users_enums.UserRoleAdmin {
@@ -238,8 +270,14 @@ func (s *StorageService) TestStorageConnection(
}
func (s *StorageService) TestStorageConnectionDirect(
user *users_models.User,
storage *Storage,
) error {
if config.GetEnv().IsCloud && storage.Type == StorageTypeLocal &&
user.Role != users_enums.UserRoleAdmin {
return ErrLocalStorageNotAllowedInCloudMode
}
var usingStorage *Storage
if storage.ID != uuid.Nil {
@@ -339,18 +377,3 @@ func (s *StorageService) TransferStorageToWorkspace(
return nil
}
func (s *StorageService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error {
storages, err := s.storageRepository.FindByWorkspaceID(workspaceID)
if err != nil {
return fmt.Errorf("failed to get storages for workspace deletion: %w", err)
}
for _, storage := range storages {
if err := s.storageRepository.Delete(storage); err != nil {
return fmt.Errorf("failed to delete storage %s: %w", storage.ID, err)
}
}
return nil
}

View File

@@ -1,11 +1,14 @@
package system_healthcheck
import (
"context"
"databasus-backend/internal/config"
"databasus-backend/internal/features/backups/backups/backuping"
"databasus-backend/internal/features/disk"
"databasus-backend/internal/storage"
cache_utils "databasus-backend/internal/util/cache"
"errors"
"time"
)
type HealthcheckService struct {
@@ -15,6 +18,20 @@ type HealthcheckService struct {
}
func (s *HealthcheckService) IsHealthy() error {
return s.performHealthCheck()
}
func (s *HealthcheckService) performHealthCheck() error {
// Check if cache is available with PING
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
client := cache_utils.GetValkeyClient()
pingResult := client.Do(ctx, client.B().Ping().Build())
if pingResult.Error() != nil {
return errors.New("cannot connect to valkey")
}
diskUsage, err := s.diskService.GetDiskUsage()
if err != nil {
return errors.New("cannot get disk usage")
@@ -35,11 +52,16 @@ func (s *HealthcheckService) IsHealthy() error {
if !s.backupBackgroundService.IsSchedulerRunning() {
return errors.New("backups are not running for more than 5 minutes")
}
if !s.backupBackgroundService.IsBackupNodesAvailable() {
return errors.New("no backup nodes available")
}
}
if config.GetEnv().IsProcessingNode {
if !s.backuperNode.IsBackuperRunning() {
return errors.New("backuper node is not running for more than 5 minutes")
}
}

View File

@@ -385,13 +385,14 @@ func createMongodbDatabaseViaAPI(
Type: databases.DatabaseTypeMongodb,
Mongodb: &mongodbtypes.MongodbDatabase{
Host: host,
Port: port,
Port: &port,
Username: username,
Password: password,
Database: database,
AuthDatabase: authDatabase,
Version: version,
IsHttps: false,
IsSrv: false,
CpuCount: 1,
},
}
@@ -432,13 +433,14 @@ func createMongodbRestoreViaAPI(
request := restores_core.RestoreBackupRequest{
MongodbDatabase: &mongodbtypes.MongodbDatabase{
Host: host,
Port: port,
Port: &port,
Username: username,
Password: password,
Database: database,
AuthDatabase: authDatabase,
Version: version,
IsHttps: false,
IsSrv: false,
CpuCount: 1,
},
}

View File

@@ -0,0 +1,593 @@
package users_controllers
import (
"net/http"
"testing"
"time"
users_dto "databasus-backend/internal/features/users/dto"
users_enums "databasus-backend/internal/features/users/enums"
users_models "databasus-backend/internal/features/users/models"
users_services "databasus-backend/internal/features/users/services"
users_testing "databasus-backend/internal/features/users/testing"
"databasus-backend/internal/storage"
test_utils "databasus-backend/internal/util/testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"golang.org/x/crypto/bcrypt"
)
func Test_SendResetPasswordCode_WithValidEmail_CodeSent(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
request := users_dto.SendResetPasswordCodeRequestDTO{
Email: user.Email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusOK,
)
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
assert.Equal(t, user.Email, mockEmailSender.SentEmails[0].To)
assert.Contains(t, mockEmailSender.SentEmails[0].Subject, "Password Reset")
}
func Test_SendResetPasswordCode_WithNonExistentUser_ReturnsSuccess(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
request := users_dto.SendResetPasswordCodeRequestDTO{
Email: "nonexistent" + uuid.New().String() + "@example.com",
}
// Should return success to prevent enumeration attacks
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusOK,
)
// But no email should be sent
assert.Equal(t, 0, len(mockEmailSender.SentEmails))
}
func Test_SendResetPasswordCode_WithInvitedUser_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
email := "invited" + uuid.New().String() + "@example.com"
inviteRequest := users_dto.InviteUserRequestDTO{
Email: email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/invite",
"Bearer "+adminUser.Token,
inviteRequest,
http.StatusOK,
)
request := users_dto.SendResetPasswordCodeRequestDTO{
Email: email,
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusBadRequest,
)
assert.Contains(t, string(resp.Body), "only active users")
}
func Test_SendResetPasswordCode_WithRateLimitExceeded_ReturnsTooManyRequests(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
request := users_dto.SendResetPasswordCodeRequestDTO{
Email: user.Email,
}
// Make 3 requests (should succeed)
for range 3 {
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusOK,
)
}
// 4th request should be rate limited
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusTooManyRequests,
)
assert.Contains(t, string(resp.Body), "Rate limit exceeded")
}
func Test_SendResetPasswordCode_WithInvalidJSON_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
Method: "POST",
URL: "/api/v1/users/send-reset-password-code",
Body: "invalid json",
ExpectedStatus: http.StatusBadRequest,
})
assert.Contains(t, string(resp.Body), "Invalid request format")
}
func Test_ResetPassword_WithValidCode_PasswordReset(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
email := "resettest" + uuid.New().String() + "@example.com"
oldPassword := "oldpassword123"
newPassword := "newpassword456"
// Create user
signupRequest := users_dto.SignUpRequestDTO{
Email: email,
Password: oldPassword,
Name: "Test User",
}
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
// Request reset code
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
Email: email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
sendCodeRequest,
http.StatusOK,
)
// Extract code from email
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
emailBody := mockEmailSender.SentEmails[0].Body
code := extractCodeFromEmail(emailBody)
t.Logf("Extracted code: %s from email body (length: %d)", code, len(code))
assert.NotEmpty(t, code, "Code should be extracted from email")
assert.Len(t, code, 6, "Code should be 6 digits")
// Reset password
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: email,
Code: code,
NewPassword: newPassword,
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusOK,
)
if resp.StatusCode != http.StatusOK {
t.Logf("Reset password failed with body: %s", string(resp.Body))
}
// Verify old password doesn't work
oldSigninRequest := users_dto.SignInRequestDTO{
Email: email,
Password: oldPassword,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/signin",
"",
oldSigninRequest,
http.StatusBadRequest,
)
// Verify new password works
newSigninRequest := users_dto.SignInRequestDTO{
Email: email,
Password: newPassword,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/signin",
"",
newSigninRequest,
http.StatusOK,
)
}
func Test_ResetPassword_WithExpiredCode_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
// Create expired reset code directly in database
code := "123456"
hashedCode, _ := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
expiredCode := &users_models.PasswordResetCode{
ID: uuid.New(),
UserID: user.UserID,
HashedCode: string(hashedCode),
ExpiresAt: time.Now().UTC().Add(-1 * time.Hour), // Expired 1 hour ago
IsUsed: false,
CreatedAt: time.Now().UTC().Add(-2 * time.Hour),
}
storage.GetDb().Create(expiredCode)
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: user.Email,
Code: code,
NewPassword: "newpassword123",
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusBadRequest,
)
assert.Contains(t, string(resp.Body), "invalid or expired")
}
func Test_ResetPassword_WithUsedCode_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
email := "usedcode" + uuid.New().String() + "@example.com"
// Create user
signupRequest := users_dto.SignUpRequestDTO{
Email: email,
Password: "password123",
Name: "Test User",
}
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
// Request reset code
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
Email: email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
sendCodeRequest,
http.StatusOK,
)
code := extractCodeFromEmail(mockEmailSender.SentEmails[0].Body)
// Use code first time
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: email,
Code: code,
NewPassword: "newpassword123",
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusOK,
)
// Try to use same code again
resetRequest2 := users_dto.ResetPasswordRequestDTO{
Email: email,
Code: code,
NewPassword: "anotherpassword456",
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest2,
http.StatusBadRequest,
)
assert.Contains(t, string(resp.Body), "invalid or expired")
}
func Test_ResetPassword_WithWrongCode_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
// Request reset code
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
Email: user.Email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
sendCodeRequest,
http.StatusOK,
)
// Try to reset with wrong code
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: user.Email,
Code: "999999", // Wrong code
NewPassword: "newpassword123",
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusBadRequest,
)
assert.Contains(t, string(resp.Body), "invalid")
}
func Test_ResetPassword_WithInvalidNewPassword_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: user.Email,
Code: "123456",
NewPassword: "short", // Too short
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusBadRequest,
)
}
func Test_ResetPassword_EmailSendFailure_ReturnsError(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
mockEmailSender.ShouldFail = true
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
request := users_dto.SendResetPasswordCodeRequestDTO{
Email: user.Email,
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusBadRequest,
)
assert.Contains(t, string(resp.Body), "failed to send email")
}
func Test_ResetPasswordFlow_E2E_CompletesSuccessfully(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
email := "e2e" + uuid.New().String() + "@example.com"
initialPassword := "initialpass123"
newPassword := "brandnewpass456"
// 1. Create user via signup
signupRequest := users_dto.SignUpRequestDTO{
Email: email,
Password: initialPassword,
Name: "E2E Test User",
}
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
// 2. Verify can sign in with initial password
signinRequest := users_dto.SignInRequestDTO{
Email: email,
Password: initialPassword,
}
var signinResponse users_dto.SignInResponseDTO
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/users/signin",
"",
signinRequest,
http.StatusOK,
&signinResponse,
)
assert.NotEmpty(t, signinResponse.Token)
// 3. Request password reset code
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
Email: email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
sendCodeRequest,
http.StatusOK,
)
// 4. Verify email was sent
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
code := extractCodeFromEmail(mockEmailSender.SentEmails[0].Body)
assert.NotEmpty(t, code)
// 5. Reset password using code
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: email,
Code: code,
NewPassword: newPassword,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusOK,
)
// 6. Verify old password no longer works
oldSignin := users_dto.SignInRequestDTO{
Email: email,
Password: initialPassword,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/signin",
"",
oldSignin,
http.StatusBadRequest,
)
// 7. Verify new password works
newSignin := users_dto.SignInRequestDTO{
Email: email,
Password: newPassword,
}
var finalResponse users_dto.SignInResponseDTO
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/users/signin",
"",
newSignin,
http.StatusOK,
&finalResponse,
)
assert.NotEmpty(t, finalResponse.Token)
}
func Test_ResetPassword_WithInvalidJSON_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
Method: "POST",
URL: "/api/v1/users/reset-password",
Body: "invalid json",
ExpectedStatus: http.StatusBadRequest,
})
assert.Contains(t, string(resp.Body), "Invalid request format")
}
// Helper function to extract 6-digit code from email HTML body
func extractCodeFromEmail(emailBody string) string {
// Look for pattern: <h1 ... >CODE</h1>
// First find <h1
h1Start := 0
for i := 0; i < len(emailBody)-3; i++ {
if emailBody[i:i+3] == "<h1" {
h1Start = i
break
}
}
if h1Start == 0 {
return ""
}
// Find the > after <h1
contentStart := h1Start
for i := h1Start; i < len(emailBody); i++ {
if emailBody[i] == '>' {
contentStart = i + 1
break
}
}
// Find </h1>
contentEnd := contentStart
for i := contentStart; i < len(emailBody)-5; i++ {
if emailBody[i:i+5] == "</h1>" {
contentEnd = i
break
}
}
if contentEnd <= contentStart {
return ""
}
// Extract content and remove whitespace
content := emailBody[contentStart:contentEnd]
code := ""
for i := 0; i < len(content); i++ {
if isDigit(content[i]) {
code += string(content[i])
}
}
if len(code) == 6 {
return code
}
return ""
}
func isDigit(b byte) bool {
return b >= '0' && b <= '9'
}

View File

@@ -28,6 +28,10 @@ func (c *UserController) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/users/admin/has-password", c.IsAdminHasPassword)
router.POST("/users/admin/set-password", c.SetAdminPassword)
// Password reset (no auth required)
router.POST("/users/send-reset-password-code", c.SendResetPasswordCode)
router.POST("/users/reset-password", c.ResetPassword)
// OAuth callbacks
router.POST("/auth/github/callback", c.HandleGitHubOAuth)
router.POST("/auth/google/callback", c.HandleGoogleOAuth)
@@ -340,3 +344,70 @@ func (c *UserController) HandleGoogleOAuth(ctx *gin.Context) {
ctx.JSON(http.StatusOK, response)
}
// SendResetPasswordCode
// @Summary Send password reset code
// @Description Send a password reset code to the user's email
// @Tags users
// @Accept json
// @Produce json
// @Param request body users_dto.SendResetPasswordCodeRequestDTO true "Email address"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 429 {object} map[string]string
// @Router /users/send-reset-password-code [post]
func (c *UserController) SendResetPasswordCode(ctx *gin.Context) {
var request user_dto.SendResetPasswordCodeRequestDTO
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
allowed, _ := c.rateLimiter.CheckLimit(
request.Email,
"reset-password",
3,
1*time.Hour,
)
if !allowed {
ctx.JSON(
http.StatusTooManyRequests,
gin.H{"error": "Rate limit exceeded. Please try again later."},
)
return
}
err := c.userService.SendResetPasswordCode(request.Email)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "If the email exists, a reset code has been sent"})
}
// ResetPassword
// @Summary Reset password with code
// @Description Reset user password using the code sent via email
// @Tags users
// @Accept json
// @Produce json
// @Param request body users_dto.ResetPasswordRequestDTO true "Reset password data"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /users/reset-password [post]
func (c *UserController) ResetPassword(ctx *gin.Context) {
var request user_dto.ResetPasswordRequestDTO
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
err := c.userService.ResetPassword(request.Email, request.Code, request.NewPassword)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
}

View File

@@ -92,3 +92,13 @@ type OAuthCallbackResponseDTO struct {
Token string `json:"token"`
IsNewUser bool `json:"isNewUser"`
}
type SendResetPasswordCodeRequestDTO struct {
Email string `json:"email" binding:"required,email"`
}
type ResetPasswordRequestDTO struct {
Email string `json:"email" binding:"required,email"`
Code string `json:"code" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=8"`
}

View File

@@ -7,3 +7,7 @@ import (
type AuditLogWriter interface {
WriteAuditLog(message string, userID *uuid.UUID, workspaceID *uuid.UUID)
}
type EmailSender interface {
SendEmail(to, subject, body string) error
}

View File

@@ -0,0 +1,24 @@
package users_models
import (
"time"
"github.com/google/uuid"
)
type PasswordResetCode struct {
ID uuid.UUID `json:"id" gorm:"column:id"`
UserID uuid.UUID `json:"userId" gorm:"column:user_id"`
HashedCode string `json:"-" gorm:"column:hashed_code"`
ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at"`
IsUsed bool `json:"isUsed" gorm:"column:is_used"`
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"`
}
func (PasswordResetCode) TableName() string {
return "password_reset_codes"
}
func (p *PasswordResetCode) IsValid() bool {
return !p.IsUsed && time.Now().UTC().Before(p.ExpiresAt)
}

View File

@@ -2,6 +2,7 @@ package users_repositories
var userRepository = &UserRepository{}
var usersSettingsRepository = &UsersSettingsRepository{}
var passwordResetRepository = &PasswordResetRepository{}
func GetUserRepository() *UserRepository {
return userRepository
@@ -10,3 +11,7 @@ func GetUserRepository() *UserRepository {
func GetUsersSettingsRepository() *UsersSettingsRepository {
return usersSettingsRepository
}
func GetPasswordResetRepository() *PasswordResetRepository {
return passwordResetRepository
}

View File

@@ -0,0 +1,61 @@
package users_repositories
import (
"time"
users_models "databasus-backend/internal/features/users/models"
"databasus-backend/internal/storage"
"github.com/google/uuid"
)
type PasswordResetRepository struct{}
func (r *PasswordResetRepository) CreateResetCode(code *users_models.PasswordResetCode) error {
if code.ID == uuid.Nil {
code.ID = uuid.New()
}
return storage.GetDb().Create(code).Error
}
func (r *PasswordResetRepository) GetValidCodeByUserID(
userID uuid.UUID,
) (*users_models.PasswordResetCode, error) {
var code users_models.PasswordResetCode
err := storage.GetDb().
Where("user_id = ? AND is_used = ? AND expires_at > ?", userID, false, time.Now().UTC()).
Order("created_at DESC").
First(&code).Error
if err != nil {
return nil, err
}
return &code, nil
}
func (r *PasswordResetRepository) MarkCodeAsUsed(codeID uuid.UUID) error {
return storage.GetDb().Model(&users_models.PasswordResetCode{}).
Where("id = ?", codeID).
Update("is_used", true).Error
}
func (r *PasswordResetRepository) DeleteExpiredCodes() error {
return storage.GetDb().
Where("expires_at < ?", time.Now().UTC()).
Delete(&users_models.PasswordResetCode{}).Error
}
func (r *PasswordResetRepository) CountRecentCodesByUserID(
userID uuid.UUID,
since time.Time,
) (int64, error) {
var count int64
err := storage.GetDb().Model(&users_models.PasswordResetCode{}).
Where("user_id = ? AND created_at > ?", userID, since).
Count(&count).Error
return count, err
}

View File

@@ -1,6 +1,7 @@
package users_services
import (
"databasus-backend/internal/features/email"
"databasus-backend/internal/features/encryption/secrets"
users_repositories "databasus-backend/internal/features/users/repositories"
)
@@ -10,6 +11,8 @@ var userService = &UserService{
secrets.GetSecretKeyService(),
settingsService,
nil,
email.GetEmailSMTPSender(),
users_repositories.GetPasswordResetRepository(),
}
var settingsService = &SettingsService{
users_repositories.GetUsersSettingsRepository(),

View File

@@ -2,6 +2,7 @@ package users_services
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
@@ -27,16 +28,22 @@ import (
)
type UserService struct {
userRepository *users_repositories.UserRepository
secretKeyService *secrets.SecretKeyService
settingsService *SettingsService
auditLogWriter users_interfaces.AuditLogWriter
userRepository *users_repositories.UserRepository
secretKeyService *secrets.SecretKeyService
settingsService *SettingsService
auditLogWriter users_interfaces.AuditLogWriter
emailSender users_interfaces.EmailSender
passwordResetRepository *users_repositories.PasswordResetRepository
}
func (s *UserService) SetAuditLogWriter(writer users_interfaces.AuditLogWriter) {
s.auditLogWriter = writer
}
func (s *UserService) SetEmailSender(sender users_interfaces.EmailSender) {
s.emailSender = sender
}
func (s *UserService) SignUp(request *users_dto.SignUpRequestDTO) error {
existingUser, err := s.userRepository.GetUserByEmail(request.Email)
if err != nil {
@@ -798,3 +805,164 @@ func (s *UserService) fetchGitHubPrimaryEmail(
return "", errors.New("github account has no accessible email")
}
func (s *UserService) SendResetPasswordCode(email string) error {
user, err := s.userRepository.GetUserByEmail(email)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
// Silently succeed for non-existent users to prevent enumeration attacks
if user == nil {
return nil
}
// Only active users can reset passwords
if user.Status != users_enums.UserStatusActive {
return errors.New("only active users can reset their password")
}
// Check rate limiting - max 3 codes per hour
oneHourAgo := time.Now().UTC().Add(-1 * time.Hour)
recentCount, err := s.passwordResetRepository.CountRecentCodesByUserID(user.ID, oneHourAgo)
if err != nil {
return fmt.Errorf("failed to check rate limit: %w", err)
}
if recentCount >= 3 {
return errors.New("too many password reset attempts, please try again later")
}
// Generate 6-digit random code using crypto/rand for better randomness
codeNum := make([]byte, 4)
_, err = io.ReadFull(rand.Reader, codeNum)
if err != nil {
return fmt.Errorf("failed to generate random code: %w", err)
}
// Convert bytes to uint32 and modulo to get 6 digits
randomInt := uint32(
codeNum[0],
)<<24 | uint32(
codeNum[1],
)<<16 | uint32(
codeNum[2],
)<<8 | uint32(
codeNum[3],
)
code := fmt.Sprintf("%06d", randomInt%1000000)
// Hash the code
hashedCode, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash code: %w", err)
}
// Store in database with 1 hour expiration
resetCode := &users_models.PasswordResetCode{
ID: uuid.New(),
UserID: user.ID,
HashedCode: string(hashedCode),
ExpiresAt: time.Now().UTC().Add(1 * time.Hour),
IsUsed: false,
CreatedAt: time.Now().UTC(),
}
if err := s.passwordResetRepository.CreateResetCode(resetCode); err != nil {
return fmt.Errorf("failed to create reset code: %w", err)
}
// Send email with code
if s.emailSender != nil {
subject := "Password Reset Code"
body := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 20px;">
<h2 style="color: #333333; margin-bottom: 20px;">Password Reset Request</h2>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
You have requested to reset your password. Please use the following code to complete the password reset process:
</p>
<div style="background-color: #f8f9fa; border: 2px solid #e9ecef; border-radius: 8px; padding: 20px; text-align: center; margin: 30px 0;">
<h1 style="color: #2c3e50; font-size: 36px; margin: 0; letter-spacing: 8px; font-family: monospace;">%s</h1>
</div>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
This code will expire in <strong>1 hour</strong>.
</p>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
If you did not request a password reset, please ignore this email. Your password will remain unchanged.
</p>
<hr style="border: none; border-top: 1px solid #e9ecef; margin: 30px 0;">
<p style="color: #999999; font-size: 12px; line-height: 1.6;">
This is an automated message. Please do not reply to this email.
</p>
</div>
</body>
</html>
`, code)
if err := s.emailSender.SendEmail(user.Email, subject, body); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
}
// Audit log
if s.auditLogWriter != nil {
s.auditLogWriter.WriteAuditLog(
fmt.Sprintf("Password reset code sent to: %s", user.Email),
&user.ID,
nil,
)
}
return nil
}
func (s *UserService) ResetPassword(email, code, newPassword string) error {
user, err := s.userRepository.GetUserByEmail(email)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
if user == nil {
return errors.New("user with this email does not exist")
}
// Get valid reset code for user
resetCode, err := s.passwordResetRepository.GetValidCodeByUserID(user.ID)
if err != nil {
return errors.New("invalid or expired reset code")
}
// Verify code matches
err = bcrypt.CompareHashAndPassword([]byte(resetCode.HashedCode), []byte(code))
if err != nil {
return errors.New("invalid reset code")
}
// Mark code as used
if err := s.passwordResetRepository.MarkCodeAsUsed(resetCode.ID); err != nil {
return fmt.Errorf("failed to mark code as used: %w", err)
}
// Update user password
if err := s.ChangeUserPassword(user.ID, newPassword); err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
// Audit log
if s.auditLogWriter != nil {
s.auditLogWriter.WriteAuditLog(
"Password reset via email code",
&user.ID,
nil,
)
}
return nil
}

View File

@@ -0,0 +1,33 @@
package users_testing
import "errors"
type MockEmailSender struct {
SentEmails []EmailCall
ShouldFail bool
}
type EmailCall struct {
To string
Subject string
Body string
}
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
m.SentEmails = append(m.SentEmails, EmailCall{
To: to,
Subject: subject,
Body: body,
})
if m.ShouldFail {
return errors.New("mock email send failure")
}
return nil
}
func NewMockEmailSender() *MockEmailSender {
return &MockEmailSender{
SentEmails: []EmailCall{},
ShouldFail: false,
}
}

View File

@@ -5,3 +5,7 @@ import "github.com/google/uuid"
type WorkspaceDeletionListener interface {
OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error
}
type EmailSender interface {
SendEmail(to, subject, body string) error
}

View File

@@ -2,9 +2,11 @@ package workspaces_services
import (
"databasus-backend/internal/features/audit_logs"
"databasus-backend/internal/features/email"
users_services "databasus-backend/internal/features/users/services"
workspaces_interfaces "databasus-backend/internal/features/workspaces/interfaces"
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
"databasus-backend/internal/util/logger"
)
var workspaceRepository = &workspaces_repositories.WorkspaceRepository{}
@@ -26,6 +28,8 @@ var membershipService = &MembershipService{
audit_logs.GetAuditLogService(),
workspaceService,
users_services.GetSettingsService(),
email.GetEmailSMTPSender(),
logger.GetLogger(),
}
func GetWorkspaceService() *WorkspaceService {

View File

@@ -2,7 +2,9 @@ package workspaces_services
import (
"fmt"
"log/slog"
"databasus-backend/internal/config"
audit_logs "databasus-backend/internal/features/audit_logs"
users_dto "databasus-backend/internal/features/users/dto"
users_enums "databasus-backend/internal/features/users/enums"
@@ -10,6 +12,7 @@ import (
users_services "databasus-backend/internal/features/users/services"
workspaces_dto "databasus-backend/internal/features/workspaces/dto"
workspaces_errors "databasus-backend/internal/features/workspaces/errors"
workspaces_interfaces "databasus-backend/internal/features/workspaces/interfaces"
workspaces_models "databasus-backend/internal/features/workspaces/models"
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
@@ -23,6 +26,8 @@ type MembershipService struct {
auditLogService *audit_logs.AuditLogService
workspaceService *WorkspaceService
settingsService *users_services.SettingsService
emailSender workspaces_interfaces.EmailSender
logger *slog.Logger
}
func (s *MembershipService) GetMembers(
@@ -77,6 +82,12 @@ func (s *MembershipService) AddMember(
return nil, workspaces_errors.ErrInsufficientPermissionsToInviteUsers
}
// Get workspace details for email
workspace, err := s.workspaceRepository.GetWorkspaceByID(workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to get workspace: %w", err)
}
inviteRequest := &users_dto.InviteUserRequestDTO{
Email: request.Email,
IntendedWorkspaceID: &workspaceID,
@@ -88,6 +99,14 @@ func (s *MembershipService) AddMember(
return nil, err
}
// Send invitation email
subject := fmt.Sprintf("You've been invited to %s workspace", workspace.Name)
body := s.buildInvitationEmailHTML(workspace.Name, addedBy.Name, string(request.Role))
if err := s.emailSender.SendEmail(request.Email, subject, body); err != nil {
s.logger.Error("Failed to send invitation email", "email", request.Email, "error", err)
}
membership := &workspaces_models.WorkspaceMembership{
UserID: inviteResponse.ID,
WorkspaceID: workspaceID,
@@ -339,3 +358,48 @@ func (s *MembershipService) validateCanManageMembership(
return nil
}
func (s *MembershipService) buildInvitationEmailHTML(
workspaceName, inviterName, role string,
) string {
env := config.GetEnv()
signUpLink := ""
if env.DatabasusURL != "" {
signUpLink = fmt.Sprintf(`<p style="margin: 20px 0;">
<a href="%s/sign-up" style="display: inline-block; padding: 12px 24px; background-color: #0d6efd; color: white; text-decoration: none; border-radius: 4px;">
Sign up
</a>
</p>`, env.DatabasusURL)
} else {
signUpLink = `<p style="margin: 20px 0; color: #666;">
Please visit your Databasus instance to sign up and access the workspace.
</p>`
}
return fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background-color: #f8f9fa; border-radius: 8px; padding: 30px; margin: 20px 0;">
<h1 style="color: #0d6efd; margin-top: 0;">Workspace Invitation</h1>
<p style="font-size: 16px; margin: 20px 0;">
<strong>%s</strong> has invited you to join the <strong>%s</strong> workspace as a <strong>%s</strong>.
</p>
%s
<hr style="border: none; border-top: 1px solid #dee2e6; margin: 30px 0;">
<p style="font-size: 14px; color: #6c757d; margin: 0;">
This is an automated message from Databasus. If you didn't expect this invitation, you can safely ignore this email.
</p>
</div>
</body>
</html>
`, inviterName, workspaceName, role, signUpLink)
}

View File

@@ -0,0 +1,33 @@
package workspaces_testing
import "errors"
type MockEmailSender struct {
SendEmailCalls []EmailCall
ShouldFail bool
}
type EmailCall struct {
To string
Subject string
Body string
}
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
m.SendEmailCalls = append(m.SendEmailCalls, EmailCall{
To: to,
Subject: subject,
Body: body,
})
if m.ShouldFail {
return errors.New("mock email send failure")
}
return nil
}
func NewMockEmailSender() *MockEmailSender {
return &MockEmailSender{
SendEmailCalls: []EmailCall{},
ShouldFail: false,
}
}

View File

@@ -71,6 +71,7 @@ func tryInitVictoriaLogs() *VictoriaLogsWriter {
// Try to get config - this may fail early in startup
url := getVictoriaLogsURL()
username := getVictoriaLogsUsername()
password := getVictoriaLogsPassword()
if url == "" {
@@ -78,7 +79,7 @@ func tryInitVictoriaLogs() *VictoriaLogsWriter {
return nil
}
return NewVictoriaLogsWriter(url, password)
return NewVictoriaLogsWriter(url, username, password)
}
func ensureEnvLoaded() {
@@ -126,6 +127,10 @@ func getVictoriaLogsURL() string {
return os.Getenv("VICTORIA_LOGS_URL")
}
func getVictoriaLogsUsername() string {
return os.Getenv("VICTORIA_LOGS_USERNAME")
}
func getVictoriaLogsPassword() string {
return os.Getenv("VICTORIA_LOGS_PASSWORD")
}

View File

@@ -23,6 +23,7 @@ type logEntry struct {
type VictoriaLogsWriter struct {
url string
username string
password string
httpClient *http.Client
logChannel chan logEntry
@@ -33,11 +34,12 @@ type VictoriaLogsWriter struct {
logger *slog.Logger
}
func NewVictoriaLogsWriter(url, password string) *VictoriaLogsWriter {
func NewVictoriaLogsWriter(url, username, password string) *VictoriaLogsWriter {
ctx, cancel := context.WithCancel(context.Background())
writer := &VictoriaLogsWriter{
url: url,
username: username,
password: password,
httpClient: &http.Client{
Timeout: 10 * time.Second,
@@ -149,9 +151,9 @@ func (w *VictoriaLogsWriter) sendHTTP(entries []logEntry) error {
// Set headers
req.Header.Set("Content-Type", "application/x-ndjson")
// Set Basic Auth (password as username, empty password)
// Set Basic Auth (username:password)
if w.password != "" {
auth := base64.StdEncoding.EncodeToString([]byte(w.password + ":"))
auth := base64.StdEncoding.EncodeToString([]byte(w.username + ":" + w.password))
req.Header.Set("Authorization", "Basic "+auth)
}

View File

@@ -0,0 +1,31 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE password_reset_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
hashed_code TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE password_reset_codes
ADD CONSTRAINT fk_password_reset_codes_user_id
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE CASCADE;
CREATE INDEX idx_password_reset_codes_user_id ON password_reset_codes (user_id);
CREATE INDEX idx_password_reset_codes_expires_at ON password_reset_codes (expires_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_password_reset_codes_expires_at;
DROP INDEX IF EXISTS idx_password_reset_codes_user_id;
DROP TABLE IF EXISTS password_reset_codes;
-- +goose StatementEnd

View File

@@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE mongodb_databases ALTER COLUMN port DROP NOT NULL;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE mongodb_databases ADD COLUMN is_srv BOOLEAN NOT NULL DEFAULT FALSE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE mongodb_databases DROP COLUMN is_srv;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE mongodb_databases ALTER COLUMN port SET NOT NULL;
-- +goose StatementEnd

View File

@@ -1 +1,5 @@
MODE=development
MODE=development
VITE_GITHUB_CLIENT_ID=
VITE_GOOGLE_CLIENT_ID=
VITE_IS_EMAIL_CONFIGURED=false
VITE_IS_CLOUD=false

View File

@@ -2,6 +2,7 @@ interface RuntimeConfig {
IS_CLOUD?: string;
GITHUB_CLIENT_ID?: string;
GOOGLE_CLIENT_ID?: string;
IS_EMAIL_CONFIGURED?: string;
}
declare global {
@@ -23,11 +24,8 @@ export function getApplicationServer() {
}
}
export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://databasus.com/storages/google-oauth';
export const APP_VERSION = (import.meta.env.VITE_APP_VERSION as string) || 'dev';
// First try runtime config, then build-time env var, then default to false
export const IS_CLOUD =
window.__RUNTIME_CONFIG__?.IS_CLOUD === 'true' || import.meta.env.VITE_IS_CLOUD === 'true';
@@ -37,6 +35,10 @@ export const GITHUB_CLIENT_ID =
export const GOOGLE_CLIENT_ID =
window.__RUNTIME_CONFIG__?.GOOGLE_CLIENT_ID || import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
export const IS_EMAIL_CONFIGURED =
window.__RUNTIME_CONFIG__?.IS_EMAIL_CONFIGURED === 'true' ||
import.meta.env.VITE_IS_EMAIL_CONFIGURED === 'true';
export function getOAuthRedirectUri(): string {
return `${window.location.origin}/auth/callback`;
}

View File

@@ -32,6 +32,7 @@ describe('MongodbConnectionStringParser', () => {
expect(result.database).toBe('mydb');
expect(result.authDatabase).toBe('admin');
expect(result.useTls).toBe(false);
expect(result.isSrv).toBe(false);
});
it('should parse connection string without database', () => {
@@ -46,6 +47,7 @@ describe('MongodbConnectionStringParser', () => {
expect(result.database).toBe('');
expect(result.authDatabase).toBe('admin');
expect(result.useTls).toBe(false);
expect(result.isSrv).toBe(false);
});
it('should default port to 27017 when not specified', () => {
@@ -107,6 +109,7 @@ describe('MongodbConnectionStringParser', () => {
expect(result.password).toBe('atlaspass');
expect(result.database).toBe('mydb');
expect(result.useTls).toBe(true); // SRV connections use TLS by default
expect(result.isSrv).toBe(true);
});
it('should parse mongodb+srv:// without database', () => {
@@ -119,6 +122,7 @@ describe('MongodbConnectionStringParser', () => {
expect(result.host).toBe('cluster0.abc123.mongodb.net');
expect(result.database).toBe('');
expect(result.useTls).toBe(true);
expect(result.isSrv).toBe(true);
});
});
@@ -314,13 +318,15 @@ describe('MongodbConnectionStringParser', () => {
expect(result.format).toBe('key-value');
});
it('should return error for key-value format missing password', () => {
const result = expectError(
it('should allow missing password in key-value format (returns empty password)', () => {
const result = expectSuccess(
MongodbConnectionStringParser.parse('host=localhost database=mydb user=admin'),
);
expect(result.error).toContain('Password');
expect(result.format).toBe('key-value');
expect(result.host).toBe('localhost');
expect(result.username).toBe('admin');
expect(result.password).toBe('');
expect(result.database).toBe('mydb');
});
});
@@ -351,12 +357,15 @@ describe('MongodbConnectionStringParser', () => {
expect(result.error).toContain('Username');
});
it('should return error for missing password in URI', () => {
const result = expectError(
it('should allow missing password in URI (returns empty password)', () => {
const result = expectSuccess(
MongodbConnectionStringParser.parse('mongodb://user@host:27017/db'),
);
expect(result.error).toContain('Password');
expect(result.username).toBe('user');
expect(result.password).toBe('');
expect(result.host).toBe('host');
expect(result.database).toBe('db');
});
it('should return error for mysql:// format (wrong database type)', () => {
@@ -446,4 +455,67 @@ describe('MongodbConnectionStringParser', () => {
expect(result.database).toBe('');
});
});
describe('Password Placeholder Handling', () => {
it('should treat <db_password> placeholder as empty password in URI format', () => {
const result = expectSuccess(
MongodbConnectionStringParser.parse('mongodb://user:<db_password>@host:27017/db'),
);
expect(result.username).toBe('user');
expect(result.password).toBe('');
expect(result.host).toBe('host');
expect(result.database).toBe('db');
});
it('should treat <password> placeholder as empty password in URI format', () => {
const result = expectSuccess(
MongodbConnectionStringParser.parse('mongodb://user:<password>@host:27017/db'),
);
expect(result.username).toBe('user');
expect(result.password).toBe('');
expect(result.host).toBe('host');
expect(result.database).toBe('db');
});
it('should treat <db_password> placeholder as empty password in SRV format', () => {
const result = expectSuccess(
MongodbConnectionStringParser.parse(
'mongodb+srv://user:<db_password>@cluster0.mongodb.net/db',
),
);
expect(result.username).toBe('user');
expect(result.password).toBe('');
expect(result.host).toBe('cluster0.mongodb.net');
expect(result.isSrv).toBe(true);
});
it('should treat <db_password> placeholder as empty password in key-value format', () => {
const result = expectSuccess(
MongodbConnectionStringParser.parse(
'host=localhost database=mydb user=admin password=<db_password>',
),
);
expect(result.host).toBe('localhost');
expect(result.username).toBe('admin');
expect(result.password).toBe('');
expect(result.database).toBe('mydb');
});
it('should treat <password> placeholder as empty password in key-value format', () => {
const result = expectSuccess(
MongodbConnectionStringParser.parse(
'host=localhost database=mydb user=admin password=<password>',
),
);
expect(result.host).toBe('localhost');
expect(result.username).toBe('admin');
expect(result.password).toBe('');
expect(result.database).toBe('mydb');
});
});
});

View File

@@ -6,6 +6,7 @@ export type ParseResult = {
database: string;
authDatabase: string;
useTls: boolean;
isSrv: boolean;
};
export type ParseError = {
@@ -63,7 +64,8 @@ export class MongodbConnectionStringParser {
const host = url.hostname;
const port = url.port ? parseInt(url.port, 10) : isSrv ? 27017 : 27017;
const username = decodeURIComponent(url.username);
const password = decodeURIComponent(url.password);
const rawPassword = decodeURIComponent(url.password);
const password = this.isPasswordPlaceholder(rawPassword) ? '' : rawPassword;
const database = decodeURIComponent(url.pathname.slice(1));
const authDatabase = this.getAuthSource(url.search) || 'admin';
const useTls = isSrv ? true : this.checkTlsMode(url.search);
@@ -76,10 +78,6 @@ export class MongodbConnectionStringParser {
return { error: 'Username is missing from connection string' };
}
if (!password) {
return { error: 'Password is missing from connection string' };
}
return {
host,
port,
@@ -88,6 +86,7 @@ export class MongodbConnectionStringParser {
database: database || '',
authDatabase,
useTls,
isSrv,
};
} catch (e) {
return {
@@ -114,7 +113,8 @@ export class MongodbConnectionStringParser {
const port = params['port'];
const database = params['database'] || params['dbname'] || params['db'];
const username = params['user'] || params['username'];
const password = params['password'];
const rawPassword = params['password'];
const password = this.isPasswordPlaceholder(rawPassword) ? '' : rawPassword || '';
const authDatabase = params['authSource'] || params['authDatabase'] || 'admin';
const tls = params['tls'] || params['ssl'];
@@ -132,13 +132,6 @@ export class MongodbConnectionStringParser {
};
}
if (!password) {
return {
error: 'Password is missing from connection string. Use password=yourpassword',
format: 'key-value',
};
}
const useTls = this.isTlsEnabled(tls);
return {
@@ -149,6 +142,7 @@ export class MongodbConnectionStringParser {
database: database || '',
authDatabase,
useTls,
isSrv: false,
};
} catch (e) {
return {
@@ -191,4 +185,11 @@ export class MongodbConnectionStringParser {
const enabledValues = ['true', 'yes', '1'];
return enabledValues.includes(lowercased);
}
private static isPasswordPlaceholder(password: string | null | undefined): boolean {
if (!password) return false;
const trimmed = password.trim();
return trimmed === '<db_password>' || trimmed === '<password>';
}
}

View File

@@ -10,5 +10,6 @@ export interface MongodbDatabase {
database: string;
authDatabase: string;
isHttps: boolean;
isSrv: boolean;
cpuCount: number;
}

View File

@@ -2,5 +2,4 @@ export interface GoogleDriveStorage {
clientId: string;
clientSecret: string;
tokenJson?: string;
useLocalRedirect?: boolean;
}

View File

@@ -1,7 +1,6 @@
import type { Storage } from './Storage';
export interface StorageOauthDto {
redirectUrl: string;
storage: Storage;
authCode: string;
}

View File

@@ -8,6 +8,8 @@ import type { InviteUserResponse } from '../model/InviteUserResponse';
import type { IsAdminHasPasswordResponse } from '../model/IsAdminHasPasswordResponse';
import type { OAuthCallbackRequest } from '../model/OAuthCallbackRequest';
import type { OAuthCallbackResponse } from '../model/OAuthCallbackResponse';
import type { ResetPasswordRequest } from '../model/ResetPasswordRequest';
import type { SendResetPasswordCodeRequest } from '../model/SendResetPasswordCodeRequest';
import type { SetAdminPasswordRequest } from '../model/SetAdminPasswordRequest';
import type { SignInRequest } from '../model/SignInRequest';
import type { SignInResponse } from '../model/SignInResponse';
@@ -134,6 +136,24 @@ export const userApi = {
});
},
async sendResetPasswordCode(request: SendResetPasswordCodeRequest): Promise<{ message: string }> {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(request));
return apiHelper.fetchPostJson(
`${getApplicationServer()}/api/v1/users/send-reset-password-code`,
requestOptions,
);
},
async resetPassword(request: ResetPasswordRequest): Promise<{ message: string }> {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(request));
return apiHelper.fetchPostJson(
`${getApplicationServer()}/api/v1/users/reset-password`,
requestOptions,
);
},
isAuthorized: (): boolean => !!accessTokenHelper.getAccessToken(),
logout: () => {

View File

@@ -18,5 +18,7 @@ export type { ListUsersRequest } from './model/ListUsersRequest';
export type { ListUsersResponse } from './model/ListUsersResponse';
export type { ChangeUserRoleRequest } from './model/ChangeUserRoleRequest';
export type { UsersSettings } from './model/UsersSettings';
export type { SendResetPasswordCodeRequest } from './model/SendResetPasswordCodeRequest';
export type { ResetPasswordRequest } from './model/ResetPasswordRequest';
export { UserRole } from './model/UserRole';
export { WorkspaceRole } from './model/WorkspaceRole';

View File

@@ -0,0 +1,5 @@
export interface ResetPasswordRequest {
email: string;
code: string;
newPassword: string;
}

View File

@@ -0,0 +1,3 @@
export interface SendResetPasswordCodeRequest {
email: string;
}

View File

@@ -204,6 +204,13 @@ export const EditBackupConfigComponent = ({
try {
const storages = await storageApi.getStorages(database.workspaceId);
setStorages(storages);
if (IS_CLOUD) {
const systemStorages = storages.filter((s) => s.isSystem);
if (systemStorages.length > 0) {
updateBackupConfig({ storage: systemStorages[0] });
}
}
} catch (e) {
alert((e as Error).message);
}

View File

@@ -402,7 +402,7 @@ export const EditMariaDbSpecificDataComponent = ({
)}
</div>
{isConnectionFailed && (
{isConnectionFailed && !IS_CLOUD && (
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
list.

View File

@@ -46,7 +46,7 @@ export const EditMongoDbSpecificDataComponent = ({
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [isConnectionFailed, setIsConnectionFailed] = useState(false);
const hasAdvancedValues = !!database.mongodb?.authDatabase;
const hasAdvancedValues = !!database.mongodb?.authDatabase || !!database.mongodb?.isSrv;
const [isShowAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
const parseFromClipboard = async () => {
@@ -75,17 +75,29 @@ export const EditMongoDbSpecificDataComponent = ({
host: result.host,
port: result.port,
username: result.username,
password: result.password,
password: result.password || '',
database: result.database,
authDatabase: result.authDatabase,
isHttps: result.useTls,
isSrv: result.isSrv,
cpuCount: 1,
},
};
if (result.isSrv) {
setShowAdvanced(true);
}
setEditingDatabase(updatedDatabase);
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
if (!result.password) {
message.warning(
'Connection string parsed successfully. Please enter the password manually.',
);
} else {
message.success('Connection string parsed successfully');
}
} catch {
message.error('Failed to read clipboard. Please check browser permissions.');
}
@@ -156,9 +168,11 @@ export const EditMongoDbSpecificDataComponent = ({
if (!editingDatabase) return null;
const isSrvConnection = editingDatabase.mongodb?.isSrv || false;
let isAllFieldsFilled = true;
if (!editingDatabase.mongodb?.host) isAllFieldsFilled = false;
if (!editingDatabase.mongodb?.port) isAllFieldsFilled = false;
if (!isSrvConnection && !editingDatabase.mongodb?.port) isAllFieldsFilled = false;
if (!editingDatabase.mongodb?.username) isAllFieldsFilled = false;
if (!editingDatabase.id && !editingDatabase.mongodb?.password) isAllFieldsFilled = false;
if (!editingDatabase.mongodb?.database) isAllFieldsFilled = false;
@@ -220,25 +234,27 @@ export const EditMongoDbSpecificDataComponent = ({
</div>
)}
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Port</div>
<InputNumber
type="number"
value={editingDatabase.mongodb?.port}
onChange={(e) => {
if (!editingDatabase.mongodb || e === null) return;
{!isSrvConnection && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Port</div>
<InputNumber
type="number"
value={editingDatabase.mongodb?.port}
onChange={(e) => {
if (!editingDatabase.mongodb || e === null) return;
setEditingDatabase({
...editingDatabase,
mongodb: { ...editingDatabase.mongodb, port: e },
});
setIsConnectionTested(false);
}}
size="small"
className="max-w-[200px] grow"
placeholder="27017"
/>
</div>
setEditingDatabase({
...editingDatabase,
mongodb: { ...editingDatabase.mongodb, port: e },
});
setIsConnectionTested(false);
}}
size="small"
className="max-w-[200px] grow"
placeholder="27017"
/>
</div>
)}
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Username</div>
@@ -366,6 +382,31 @@ export const EditMongoDbSpecificDataComponent = ({
{isShowAdvanced && (
<>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Use SRV connection</div>
<div className="flex items-center">
<Switch
checked={editingDatabase.mongodb?.isSrv || false}
onChange={(checked) => {
if (!editingDatabase.mongodb) return;
setEditingDatabase({
...editingDatabase,
mongodb: { ...editingDatabase.mongodb, isSrv: checked },
});
setIsConnectionTested(false);
}}
size="small"
/>
<Tooltip
className="cursor-pointer"
title="Enable for MongoDB Atlas SRV connections (mongodb+srv://). Port is not required for SRV connections."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Auth database</div>
<Input
@@ -425,7 +466,7 @@ export const EditMongoDbSpecificDataComponent = ({
)}
</div>
{isConnectionFailed && (
{isConnectionFailed && !IS_CLOUD && (
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
list.

View File

@@ -353,7 +353,7 @@ export const EditMySqlSpecificDataComponent = ({
)}
</div>
{isConnectionFailed && (
{isConnectionFailed && !IS_CLOUD && (
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
list.

View File

@@ -514,7 +514,7 @@ export const EditPostgreSqlSpecificDataComponent = ({
)}
</div>
{isConnectionFailed && (
{isConnectionFailed && !IS_CLOUD && (
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
list.

View File

@@ -59,7 +59,7 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
return (
<Modal
title="Welcome to Databasus Playground"
title="Welcome to Databasus playground"
open={isVisible}
onOk={handleClose}
okText={
@@ -78,9 +78,10 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
<div>
<h3 className="mb-2 text-lg font-semibold">What is Playground?</h3>
<p className="text-gray-700 dark:text-gray-300">
Playground is a dev environment where you can test small databases backup and see
Databasus in action. Databasus dev team can test new features and see issues which hard
to detect when using self hosted (without logs or reports)
Playground is a dev environment of Databasus development team. It is used by Databasus
dev team to test new features and see issues which hard to detect when using self hosted
(without logs or reports).{' '}
<b>Here you can make backups for small and not critical databases for free</b>
</p>
</div>
@@ -114,7 +115,8 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
No, because playground use only read-only users and cannot affect your DB. Only issue
you can face is instability: playground background workers frequently reloaded so backup
can be slower or be restarted due to app restart. Do not rely production DBs on
playground, please
playground, please. At once we may clean backups or something like this. At least, check
your backups here once a week
</p>
</div>

View File

@@ -218,7 +218,7 @@ export function SettingsComponent({ contentHeight }: Props) {
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
Read more about settings you can{' '}
<a
href="https://databasus.com/access-management/#global-settings"
href="https://databasus.com/access-management#global-settings"
target="_blank"
rel="noreferrer"
className="!text-blue-600"

View File

@@ -42,7 +42,7 @@ export const StorageCardComponent = ({
)}
{storage.isSystem && (
<div className="mt-2 inline-block rounded-lg bg-[#ffffff10] px-2 py-1 text-xs text-gray-700 dark:text-gray-300">
<div className="mt-2 inline-block rounded-xl bg-[#00000010] px-2 py-1 text-xs text-gray-700 dark:bg-[#ffffff10] dark:text-gray-300">
System storage
</div>
)}

View File

@@ -143,15 +143,22 @@ export const StorageComponent = ({
) : (
<div>
{!isEditName ? (
<div className="mb-5 flex items-center text-2xl font-bold">
{storage.name}
{(storage.isSystem && user.role === UserRole.ADMIN) ||
(isCanManageStorages && (
<>
<div className="mb-5 flex items-center text-2xl font-bold">
{storage.name}
{(!storage.isSystem || user.role === UserRole.ADMIN) && isCanManageStorages && (
<div className="ml-2 cursor-pointer" onClick={() => startEdit('name')}>
<img src="/icons/pen-gray.svg" />
</div>
))}
</div>
)}
</div>
{storage.isSystem && (
<span className="mt-2 inline-block rounded-xl bg-[#00000010] px-2 py-1 text-xs text-gray-700 dark:bg-[#ffffff10] dark:text-gray-300">
System storage
</span>
)}
</>
) : (
<div>
<div className="flex items-center">
@@ -220,19 +227,22 @@ export const StorageComponent = ({
</div>
)}
<div className="mt-5 flex items-center font-bold">
<div>Storage settings</div>
{(!storage.isSystem || user.role === UserRole.ADMIN) && (
<div className="mt-5 flex items-center font-bold">
<div>Storage settings</div>
{!isEditSettings &&
isCanManageStorages &&
!(storage.isSystem && user.role !== UserRole.ADMIN) ? (
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('settings')}>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
{!isEditSettings && isCanManageStorages ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('settings')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
)}
<div className="mt-1 text-sm">
{isEditSettings && isCanManageStorages ? (
@@ -254,7 +264,7 @@ export const StorageComponent = ({
)}
</div>
{!isEditSettings && (
{!isEditSettings && (!storage.isSystem || user.role === UserRole.ADMIN) && (
<div className="mt-5">
<Button
type="primary"

View File

@@ -193,9 +193,18 @@ export function EditStorageComponent({
id: undefined as unknown as string,
workspaceId,
name: '',
type: StorageType.LOCAL,
type: IS_CLOUD ? StorageType.S3 : StorageType.LOCAL,
isSystem: false,
localStorage: {},
localStorage: IS_CLOUD ? undefined : {},
s3Storage: IS_CLOUD
? {
s3Bucket: '',
s3Region: '',
s3AccessKey: '',
s3SecretKey: '',
s3Endpoint: '',
}
: undefined,
},
);
}, [editingStorage]);
@@ -317,6 +326,22 @@ export function EditStorageComponent({
if (!storage) return <div />;
const storageTypeOptions = [
{ label: 'Local storage', value: StorageType.LOCAL },
{ label: 'S3', value: StorageType.S3 },
{ label: 'Google Drive', value: StorageType.GOOGLE_DRIVE },
{ label: 'NAS', value: StorageType.NAS },
{ label: 'Azure Blob Storage', value: StorageType.AZURE_BLOB },
{ label: 'FTP', value: StorageType.FTP },
{ label: 'SFTP', value: StorageType.SFTP },
{ label: 'Rclone', value: StorageType.RCLONE },
].filter((option) => {
if (IS_CLOUD && option.value === StorageType.LOCAL && user.role !== UserRole.ADMIN) {
return false;
}
return true;
});
return (
<div>
{isShowName && (
@@ -342,16 +367,7 @@ export function EditStorageComponent({
<div className="flex items-center">
<Select
value={storage?.type}
options={[
{ label: 'Local storage', value: StorageType.LOCAL },
{ label: 'S3', value: StorageType.S3 },
{ label: 'Google Drive', value: StorageType.GOOGLE_DRIVE },
{ label: 'NAS', value: StorageType.NAS },
{ label: 'Azure Blob Storage', value: StorageType.AZURE_BLOB },
{ label: 'FTP', value: StorageType.FTP },
{ label: 'SFTP', value: StorageType.SFTP },
{ label: 'Rclone', value: StorageType.RCLONE },
]}
options={storageTypeOptions}
onChange={(value) => {
setStorageType(value);
setIsUnsaved(true);

View File

@@ -1,8 +1,5 @@
import { DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons';
import { Button, Checkbox, Input, Tooltip } from 'antd';
import { useState } from 'react';
import { Button, Input } from 'antd';
import { GOOGLE_DRIVE_OAUTH_REDIRECT_URL } from '../../../../../constants';
import type { Storage } from '../../../../../entity/storages';
import type { StorageOauthDto } from '../../../../../entity/storages/models/StorageOauthDto';
@@ -13,23 +10,16 @@ interface Props {
}
export function EditGoogleDriveStorageComponent({ storage, setStorage, setUnsaved }: Props) {
const hasAdvancedValues = !!storage?.googleDriveStorage?.useLocalRedirect;
const [showAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
const goToAuthUrl = () => {
if (!storage?.googleDriveStorage?.clientId || !storage?.googleDriveStorage?.clientSecret) {
return;
}
const localRedirectUri = `${window.location.origin}/storages/google-oauth`;
const useLocal = storage.googleDriveStorage.useLocalRedirect;
const redirectUri = useLocal ? localRedirectUri : GOOGLE_DRIVE_OAUTH_REDIRECT_URL;
const redirectUri = `${window.location.origin}/storages/google-oauth`;
const clientId = storage.googleDriveStorage.clientId;
const scope = 'https://www.googleapis.com/auth/drive.file';
const originUrl = `${window.location.origin}/storages/google-oauth`;
const oauthDto: StorageOauthDto = {
redirectUrl: originUrl,
storage: storage,
authCode: '',
};
@@ -99,53 +89,6 @@ export function EditGoogleDriveStorageComponent({ storage, setStorage, setUnsave
/>
</div>
<div className="mt-4 mb-3 flex items-center">
<div
className="flex cursor-pointer items-center text-sm text-blue-600 hover:text-blue-800"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<span className="mr-2">Advanced settings</span>
{showAdvanced ? (
<UpOutlined style={{ fontSize: '12px' }} />
) : (
<DownOutlined style={{ fontSize: '12px' }} />
)}
</div>
</div>
{showAdvanced && (
<div className="mb-4 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="flex items-center">
<Checkbox
checked={storage?.googleDriveStorage?.useLocalRedirect || false}
onChange={(e) => {
if (!storage?.googleDriveStorage) return;
setStorage({
...storage,
googleDriveStorage: {
...storage.googleDriveStorage,
useLocalRedirect: e.target.checked,
},
});
setUnsaved();
}}
disabled={!!storage?.googleDriveStorage?.tokenJson}
>
<span>Use local redirect</span>
</Checkbox>
<Tooltip
className="cursor-pointer"
title="When enabled, uses your address as the origin and redirect URL (specify it in Google Cloud Console). HTTPS is required."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
)}
{storage?.googleDriveStorage?.tokenJson && (
<>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">

View File

@@ -18,6 +18,8 @@ interface Props {
export function ShowStorageComponent({ storage, user }: Props) {
if (!storage) return null;
if (storage?.isSystem && user.role !== UserRole.ADMIN) return <div />;
return (
<div>
<div className="mb-1 flex items-center">
@@ -39,33 +41,23 @@ export function ShowStorageComponent({ storage, user }: Props) {
</div>
)}
<div>{storage?.type === StorageType.S3 && <ShowS3StorageComponent storage={storage} />}</div>
<div>
{storage?.type === StorageType.S3 && <ShowS3StorageComponent storage={storage} />}
{storage?.type === StorageType.GOOGLE_DRIVE && (
<ShowGoogleDriveStorageComponent storage={storage} />
)}
</div>
<div>
{storage?.type === StorageType.NAS && <ShowNASStorageComponent storage={storage} />}
</div>
<div>
{storage?.type === StorageType.AZURE_BLOB && (
<ShowAzureBlobStorageComponent storage={storage} />
)}
</div>
<div>
{storage?.type === StorageType.FTP && <ShowFTPStorageComponent storage={storage} />}
</div>
<div>
{storage?.type === StorageType.SFTP && <ShowSFTPStorageComponent storage={storage} />}
</div>
<div>
{storage?.type === StorageType.RCLONE && <ShowRcloneStorageComponent storage={storage} />}
</div>
</div>

View File

@@ -1,5 +1,7 @@
export { AdminPasswordComponent } from './ui/AdminPasswordComponent';
export { AuthNavbarComponent } from './ui/AuthNavbarComponent';
export { ProfileComponent } from './ui/ProfileComponent';
export { RequestResetPasswordComponent } from './ui/RequestResetPasswordComponent';
export { ResetPasswordComponent } from './ui/ResetPasswordComponent';
export { SignInComponent } from './ui/SignInComponent';
export { SignUpComponent } from './ui/SignUpComponent';

View File

@@ -0,0 +1,123 @@
import { Button, Input } from 'antd';
import { type JSX, useState } from 'react';
import { userApi } from '../../../entity/users';
import { StringUtils } from '../../../shared/lib';
import { FormValidator } from '../../../shared/lib/FormValidator';
interface RequestResetPasswordComponentProps {
onSwitchToSignIn?: () => void;
onSwitchToResetPassword?: (email: string) => void;
}
export function RequestResetPasswordComponent({
onSwitchToSignIn,
onSwitchToResetPassword,
}: RequestResetPasswordComponentProps): JSX.Element {
const [email, setEmail] = useState('');
const [isLoading, setLoading] = useState(false);
const [isEmailError, setEmailError] = useState(false);
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const validateEmail = (): boolean => {
if (!email) {
setEmailError(true);
return false;
}
if (!FormValidator.isValidEmail(email)) {
setEmailError(true);
return false;
}
return true;
};
const onSendCode = async () => {
setError('');
setSuccessMessage('');
if (validateEmail()) {
setLoading(true);
try {
const response = await userApi.sendResetPasswordCode({ email });
setSuccessMessage(response.message);
// After successful code send, switch to reset password form
setTimeout(() => {
if (onSwitchToResetPassword) {
onSwitchToResetPassword(email);
}
}, 2000);
} catch (e) {
setError(StringUtils.capitalizeFirstLetter((e as Error).message));
}
setLoading(false);
}
};
return (
<div className="w-full max-w-[300px]">
<div className="mb-5 text-center text-2xl font-bold">Reset password</div>
<div className="mb-4 text-center text-sm text-gray-600 dark:text-gray-400">
Enter your email address and we&apos;ll send you a reset code.
</div>
<div className="my-1 text-xs font-semibold">Your email</div>
<Input
placeholder="your@email.com"
value={email}
onChange={(e) => {
setEmailError(false);
setEmail(e.currentTarget.value.trim().toLowerCase());
}}
status={isEmailError ? 'error' : undefined}
type="email"
onPressEnter={() => {
onSendCode();
}}
/>
<div className="mt-3" />
<Button
disabled={isLoading}
loading={isLoading}
className="w-full"
onClick={() => {
onSendCode();
}}
type="primary"
>
Send reset code
</Button>
{error && (
<div className="mt-3 flex justify-center text-center text-sm text-red-600">{error}</div>
)}
{successMessage && (
<div className="mt-3 flex justify-center text-center text-sm text-green-600">
{successMessage}
</div>
)}
{onSwitchToSignIn && (
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
Remember your password?{' '}
<button
type="button"
onClick={onSwitchToSignIn}
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
>
Sign in
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,221 @@
import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
import { App, Button, Input } from 'antd';
import { type JSX, useState } from 'react';
import { userApi } from '../../../entity/users';
import { StringUtils } from '../../../shared/lib';
import { FormValidator } from '../../../shared/lib/FormValidator';
interface ResetPasswordComponentProps {
onSwitchToSignIn?: () => void;
onSwitchToRequestCode?: () => void;
initialEmail?: string;
}
export function ResetPasswordComponent({
onSwitchToSignIn,
onSwitchToRequestCode,
initialEmail = '',
}: ResetPasswordComponentProps): JSX.Element {
const { message } = App.useApp();
const [email, setEmail] = useState(initialEmail);
const [code, setCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false);
const [isLoading, setLoading] = useState(false);
const [isEmailError, setEmailError] = useState(false);
const [isCodeError, setCodeError] = useState(false);
const [passwordError, setPasswordError] = useState(false);
const [confirmPasswordError, setConfirmPasswordError] = useState(false);
const [error, setError] = useState('');
const validateFields = (): boolean => {
let isValid = true;
if (!email) {
setEmailError(true);
isValid = false;
} else if (!FormValidator.isValidEmail(email)) {
setEmailError(true);
isValid = false;
} else {
setEmailError(false);
}
if (!code) {
setCodeError(true);
isValid = false;
} else if (!/^\d{6}$/.test(code)) {
setCodeError(true);
message.error('Code must be 6 digits');
isValid = false;
} else {
setCodeError(false);
}
if (!newPassword) {
setPasswordError(true);
isValid = false;
} else if (newPassword.length < 8) {
setPasswordError(true);
message.error('Password must be at least 8 characters long');
isValid = false;
} else {
setPasswordError(false);
}
if (!confirmPassword) {
setConfirmPasswordError(true);
isValid = false;
} else if (newPassword !== confirmPassword) {
setConfirmPasswordError(true);
message.error('Passwords do not match');
isValid = false;
} else {
setConfirmPasswordError(false);
}
return isValid;
};
const onResetPassword = async () => {
setError('');
if (validateFields()) {
setLoading(true);
try {
await userApi.resetPassword({
email,
code,
newPassword,
});
message.success('Password reset successfully! Redirecting to sign in...');
// Redirect to sign in after successful reset
setTimeout(() => {
if (onSwitchToSignIn) {
onSwitchToSignIn();
}
}, 2000);
} catch (e) {
setError(StringUtils.capitalizeFirstLetter((e as Error).message));
}
setLoading(false);
}
};
return (
<div className="w-full max-w-[300px]">
<div className="mb-5 text-center text-2xl font-bold">Reset Password</div>
<div className="mb-4 text-center text-sm text-gray-600 dark:text-gray-400">
Enter the code sent to your email and your new password.
</div>
<div className="my-1 text-xs font-semibold">Your email</div>
<Input
placeholder="your@email.com"
value={email}
onChange={(e) => {
setEmailError(false);
setEmail(e.currentTarget.value.trim().toLowerCase());
}}
status={isEmailError ? 'error' : undefined}
type="email"
/>
<div className="my-1 text-xs font-semibold">Reset Code</div>
<Input
placeholder="123456"
value={code}
onChange={(e) => {
setCodeError(false);
const value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6);
setCode(value);
}}
status={isCodeError ? 'error' : undefined}
maxLength={6}
/>
<div className="my-1 text-xs font-semibold">New Password</div>
<Input.Password
placeholder="********"
value={newPassword}
onChange={(e) => {
setPasswordError(false);
setNewPassword(e.currentTarget.value);
}}
status={passwordError ? 'error' : undefined}
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
visibilityToggle={{ visible: passwordVisible, onVisibleChange: setPasswordVisible }}
/>
<div className="my-1 text-xs font-semibold">Confirm Password</div>
<Input.Password
placeholder="********"
value={confirmPassword}
status={confirmPasswordError ? 'error' : undefined}
onChange={(e) => {
setConfirmPasswordError(false);
setConfirmPassword(e.currentTarget.value);
}}
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
visibilityToggle={{
visible: confirmPasswordVisible,
onVisibleChange: setConfirmPasswordVisible,
}}
/>
<div className="mt-3" />
<Button
disabled={isLoading}
loading={isLoading}
className="w-full"
onClick={() => {
onResetPassword();
}}
type="primary"
>
Reset password
</Button>
{error && (
<div className="mt-3 flex justify-center text-center text-sm text-red-600">{error}</div>
)}
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
{onSwitchToRequestCode && (
<>
Didn&apos;t receive a code?{' '}
<button
type="button"
onClick={onSwitchToRequestCode}
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
>
Request new code
</button>
<br />
</>
)}
{onSwitchToSignIn && (
<button
type="button"
onClick={onSwitchToSignIn}
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
>
Back to sign in
</button>
)}
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
import { Button, Input } from 'antd';
import { type JSX, useState } from 'react';
import { GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID } from '../../../constants';
import { GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID, IS_EMAIL_CONFIGURED } from '../../../constants';
import { userApi } from '../../../entity/users';
import { StringUtils } from '../../../shared/lib';
import { FormValidator } from '../../../shared/lib/FormValidator';
@@ -11,9 +11,13 @@ import { GoogleOAuthComponent } from './oauth/GoogleOAuthComponent';
interface SignInComponentProps {
onSwitchToSignUp?: () => void;
onSwitchToResetPassword?: () => void;
}
export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX.Element {
export function SignInComponent({
onSwitchToSignUp,
onSwitchToResetPassword,
}: SignInComponentProps): JSX.Element {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
@@ -81,7 +85,9 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500 dark:text-gray-400">or continue</span>
<span className="bg-white px-2 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
or continue
</span>
</div>
</div>
)}
@@ -131,18 +137,26 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
</div>
)}
{onSwitchToSignUp && (
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
Don&apos;t have an account?{' '}
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
Don&apos;t have an account?{' '}
<button
type="button"
onClick={onSwitchToSignUp}
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
>
Sign up
</button>
<br />
{IS_EMAIL_CONFIGURED && (
<button
type="button"
onClick={onSwitchToSignUp}
onClick={onSwitchToResetPassword}
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
>
Sign up
Forgot password?
</button>
</div>
)}
)}
</div>
</div>
);
}

View File

@@ -112,7 +112,9 @@ export function SignUpComponent({ onSwitchToSignIn }: SignUpComponentProps): JSX
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500 dark:text-gray-400">or continue</span>
<span className="bg-white px-2 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
or continue
</span>
</div>
</div>
)}

View File

@@ -101,7 +101,9 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
dataIndex: 'message',
key: 'message',
width: 350,
render: (message: string) => <span className="text-xs text-gray-900">{message}</span>,
render: (message: string) => (
<span className="text-xs text-gray-900 dark:text-white">{message}</span>
),
},
{
title: 'Workspace',
@@ -111,7 +113,9 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
render: (workspaceId: string | undefined) => (
<span
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${
workspaceId ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-600'
workspaceId
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-white'
}`}
>
{workspaceId || '-'}
@@ -127,7 +131,7 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
const date = dayjs(createdAt);
const timeFormat = getUserTimeFormat();
return (
<span className="text-xs text-gray-700">
<span className="text-xs text-gray-700 dark:text-white">
{`${date.format(timeFormat.format)} (${date.fromNow()})`}
</span>
);

View File

@@ -7,6 +7,8 @@ import { PlaygroundWarningComponent } from '../features/playground';
import {
AdminPasswordComponent,
AuthNavbarComponent,
RequestResetPasswordComponent,
ResetPasswordComponent,
SignInComponent,
SignUpComponent,
} from '../features/users';
@@ -14,7 +16,10 @@ import { useScreenHeight } from '../shared/hooks';
export function AuthPageComponent() {
const [isAdminHasPassword, setIsAdminHasPassword] = useState(false);
const [authMode, setAuthMode] = useState<'signIn' | 'signUp'>('signUp');
const [authMode, setAuthMode] = useState<'signIn' | 'signUp' | 'requestReset' | 'resetPassword'>(
'signUp',
);
const [resetEmail, setResetEmail] = useState('');
const [isLoading, setLoading] = useState(true);
const screenHeight = useScreenHeight();
@@ -51,8 +56,25 @@ export function AuthPageComponent() {
{isAdminHasPassword ? (
authMode === 'signUp' ? (
<SignUpComponent onSwitchToSignIn={() => setAuthMode('signIn')} />
) : authMode === 'signIn' ? (
<SignInComponent
onSwitchToSignUp={() => setAuthMode('signUp')}
onSwitchToResetPassword={() => setAuthMode('requestReset')}
/>
) : authMode === 'requestReset' ? (
<RequestResetPasswordComponent
onSwitchToSignIn={() => setAuthMode('signIn')}
onSwitchToResetPassword={(email) => {
setResetEmail(email);
setAuthMode('resetPassword');
}}
/>
) : (
<SignInComponent onSwitchToSignUp={() => setAuthMode('signUp')} />
<ResetPasswordComponent
onSwitchToSignIn={() => setAuthMode('signIn')}
onSwitchToRequestCode={() => setAuthMode('requestReset')}
initialEmail={resetEmail}
/>
)
) : (
<AdminPasswordComponent onPasswordSet={checkAdminPasswordStatus} />

View File

@@ -1,7 +1,6 @@
import { Modal, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { GOOGLE_DRIVE_OAUTH_REDIRECT_URL } from '../constants';
import { type Storage, StorageType } from '../entity/storages';
import type { StorageOauthDto } from '../entity/storages/models/StorageOauthDto';
import type { UserProfile } from '../entity/users';
@@ -21,7 +20,7 @@ export function OauthStorageComponent() {
const { clientId, clientSecret } = oauthDto.storage.googleDriveStorage;
const { authCode } = oauthDto;
const redirectUri = oauthDto.redirectUrl || GOOGLE_DRIVE_OAUTH_REDIRECT_URL;
const redirectUri = `${window.location.origin}/storages/google-oauth`;
try {
// Exchange authorization code for access token
@@ -84,33 +83,14 @@ export function OauthStorageComponent() {
});
const urlParams = new URLSearchParams(window.location.search);
// Attempt 1: Check for the 'oauthDto' param (Third-party/Legacy way)
const oauthDtoParam = urlParams.get('oauthDto');
if (oauthDtoParam) {
try {
const decodedParam = decodeURIComponent(oauthDtoParam);
const oauthDto: StorageOauthDto = JSON.parse(decodedParam);
processOauthDto(oauthDto);
return;
} catch (e) {
console.error('Error parsing oauthDto parameter:', e);
alert('Malformed OAuth parameter received');
return;
}
}
// Attempt 2: Check for 'code' and 'state' (Direct Google/Local way)
const code = urlParams.get('code');
const state = urlParams.get('state');
if (code && state) {
try {
// The 'state' parameter contains our stringified StorageOauthDto
const decodedState = decodeURIComponent(state);
const oauthDto: StorageOauthDto = JSON.parse(decodedState);
// Inject the authorization code received from Google
oauthDto.authCode = code;
processOauthDto(oauthDto);
@@ -122,7 +102,6 @@ export function OauthStorageComponent() {
}
}
// Attempt 3: No valid parameters found
alert('OAuth param not found. Ensure the redirect URL is configured correctly.');
}, []);