From a809dc8a9c1dc99f1adb42c37d3cb68dd96a5627 Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Sat, 8 Nov 2025 18:49:23 +0300 Subject: [PATCH] FEATURE (protection): Do not expose sensetive data of databases, notifiers and storages from API + make backups lazy loaded --- .../features/backups/backups/controller.go | 18 +- .../backups/backups/controller_test.go | 11 +- .../internal/features/backups/backups/dto.go | 14 + .../features/backups/backups/repository.go | 35 ++ .../features/backups/backups/service.go | 24 +- .../features/backups/config/service.go | 13 - .../features/databases/controller_test.go | 158 +++++++++ .../databases/databases/postgresql/model.go | 17 + .../internal/features/databases/interfaces.go | 2 + backend/internal/features/databases/model.go | 16 + .../internal/features/databases/service.go | 46 ++- .../internal/features/notifiers/controller.go | 5 - .../features/notifiers/controller_test.go | 301 ++++++++++++++++++ .../internal/features/notifiers/interfaces.go | 2 + backend/internal/features/notifiers/model.go | 36 +++ .../notifiers/models/discord/model.go | 10 + .../notifiers/models/email_notifier/model.go | 15 + .../features/notifiers/models/slack/model.go | 12 + .../features/notifiers/models/teams/model.go | 10 + .../notifiers/models/telegram/model.go | 13 + .../notifiers/models/webhook/model.go | 8 + .../internal/features/notifiers/service.go | 68 +++- .../internal/features/storages/controller.go | 10 - .../features/storages/controller_test.go | 156 +++++++++ .../internal/features/storages/interfaces.go | 2 + backend/internal/features/storages/model.go | 28 ++ .../storages/models/google_drive/model.go | 17 + .../features/storages/models/local/model.go | 6 + .../features/storages/models/nas/model.go | 18 ++ .../features/storages/models/s3/model.go | 19 ++ backend/internal/features/storages/service.go | 69 +++- frontend/src/App.tsx | 2 + frontend/src/entity/backups/api/backupsApi.ts | 12 +- .../backups/model/GetBackupsResponse.ts | 8 + .../models/discord/validateDiscordNotifier.ts | 4 +- .../models/email/validateEmailNotifier.ts | 6 +- .../models/slack/validateSlackNotifier.ts | 4 +- .../models/teams/validateTeamsNotifier.ts | 4 +- .../telegram/validateTelegramNotifier.ts | 7 +- .../models/webhook/validateWebhookNotifier.ts | 4 +- .../features/backups/ui/BackupsComponent.tsx | 183 +++++++++-- .../backups/ui/EditBackupConfigComponent.tsx | 92 +++--- .../backups/ui/ShowBackupConfigComponent.tsx | 4 +- .../databases/ui/DatabaseComponent.tsx | 16 +- .../EditDatabaseSpecificDataComponent.tsx | 4 +- .../ShowDatabaseSpecificDataComponent.tsx | 2 +- .../ui/edit/EditNotifierComponent.tsx | 12 +- .../notifier/ShowEmailNotifierComponent.tsx | 2 +- .../storages/ui/edit/EditStorageComponent.tsx | 17 + .../ShowGoogleDriveStorageComponent.tsx | 8 +- .../show/storages/ShowNASStorageComponent.tsx | 2 +- .../show/storages/ShowS3StorageComponent.tsx | 6 +- frontend/src/pages/OauthStorageComponent.tsx | 105 ++++++ 53 files changed, 1468 insertions(+), 195 deletions(-) create mode 100644 backend/internal/features/backups/backups/dto.go create mode 100644 frontend/src/entity/backups/model/GetBackupsResponse.ts create mode 100644 frontend/src/pages/OauthStorageComponent.tsx diff --git a/backend/internal/features/backups/backups/controller.go b/backend/internal/features/backups/backups/controller.go index 20f09fb..9718b99 100644 --- a/backend/internal/features/backups/backups/controller.go +++ b/backend/internal/features/backups/backups/controller.go @@ -23,11 +23,13 @@ func (c *BackupController) RegisterRoutes(router *gin.RouterGroup) { // GetBackups // @Summary Get backups for a database -// @Description Get all backups for the specified database +// @Description Get paginated backups for the specified database // @Tags backups // @Produce json // @Param database_id query string true "Database ID" -// @Success 200 {array} Backup +// @Param limit query int false "Number of items per page" default(10) +// @Param offset query int false "Offset for pagination" default(0) +// @Success 200 {object} GetBackupsResponse // @Failure 400 // @Failure 401 // @Failure 500 @@ -39,25 +41,25 @@ func (c *BackupController) GetBackups(ctx *gin.Context) { return } - databaseIDStr := ctx.Query("database_id") - if databaseIDStr == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "database_id query parameter is required"}) + var request GetBackupsRequest + if err := ctx.ShouldBindQuery(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - databaseID, err := uuid.Parse(databaseIDStr) + databaseID, err := uuid.Parse(request.DatabaseID) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database_id"}) return } - backups, err := c.backupService.GetBackups(user, databaseID) + response, err := c.backupService.GetBackups(user, databaseID, request.Limit, request.Offset) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - ctx.JSON(http.StatusOK, backups) + ctx.JSON(http.StatusOK, response) } // MakeBackup diff --git a/backend/internal/features/backups/backups/controller_test.go b/backend/internal/features/backups/backups/controller_test.go index d16d49a..8c6c9c3 100644 --- a/backend/internal/features/backups/backups/controller_test.go +++ b/backend/internal/features/backups/backups/controller_test.go @@ -102,10 +102,11 @@ func Test_GetBackups_PermissionsEnforced(t *testing.T) { ) if tt.expectSuccess { - var backups []*Backup - err := json.Unmarshal(testResp.Body, &backups) + var response GetBackupsResponse + err := json.Unmarshal(testResp.Body, &response) assert.NoError(t, err) - assert.GreaterOrEqual(t, len(backups), 1) + assert.GreaterOrEqual(t, len(response.Backups), 1) + assert.GreaterOrEqual(t, response.Total, int64(1)) } else { assert.Contains(t, string(testResp.Body), "insufficient permissions") } @@ -329,9 +330,9 @@ func Test_DeleteBackup_PermissionsEnforced(t *testing.T) { ownerUser, err := userService.GetUserFromToken(owner.Token) assert.NoError(t, err) - backups, err := GetBackupService().GetBackups(ownerUser, database.ID) + response, err := GetBackupService().GetBackups(ownerUser, database.ID, 10, 0) assert.NoError(t, err) - assert.Equal(t, 0, len(backups)) + assert.Equal(t, 0, len(response.Backups)) } }) } diff --git a/backend/internal/features/backups/backups/dto.go b/backend/internal/features/backups/backups/dto.go new file mode 100644 index 0000000..92d5c02 --- /dev/null +++ b/backend/internal/features/backups/backups/dto.go @@ -0,0 +1,14 @@ +package backups + +type GetBackupsRequest struct { + DatabaseID string `form:"database_id" binding:"required"` + Limit int `form:"limit"` + Offset int `form:"offset"` +} + +type GetBackupsResponse struct { + Backups []*Backup `json:"backups"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} diff --git a/backend/internal/features/backups/backups/repository.go b/backend/internal/features/backups/backups/repository.go index 2cc8a74..a9e44fd 100644 --- a/backend/internal/features/backups/backups/repository.go +++ b/backend/internal/features/backups/backups/repository.go @@ -195,3 +195,38 @@ func (r *BackupRepository) FindBackupsBeforeDate( return backups, nil } + +func (r *BackupRepository) FindByDatabaseIDWithPagination( + databaseID uuid.UUID, + limit, offset int, +) ([]*Backup, error) { + var backups []*Backup + + if err := storage. + GetDb(). + Preload("Database"). + Preload("Storage"). + Where("database_id = ?", databaseID). + Order("created_at DESC"). + Limit(limit). + Offset(offset). + Find(&backups).Error; err != nil { + return nil, err + } + + return backups, nil +} + +func (r *BackupRepository) CountByDatabaseID(databaseID uuid.UUID) (int64, error) { + var count int64 + + if err := storage. + GetDb(). + Model(&Backup{}). + Where("database_id = ?", databaseID). + Count(&count).Error; err != nil { + return 0, err + } + + return count, nil +} diff --git a/backend/internal/features/backups/backups/service.go b/backend/internal/features/backups/backups/service.go index c7f59c5..5a4db7e 100644 --- a/backend/internal/features/backups/backups/service.go +++ b/backend/internal/features/backups/backups/service.go @@ -93,7 +93,8 @@ func (s *BackupService) MakeBackupWithAuth( func (s *BackupService) GetBackups( user *users_models.User, databaseID uuid.UUID, -) ([]*Backup, error) { + limit, offset int, +) (*GetBackupsResponse, error) { database, err := s.databaseService.GetDatabaseByID(databaseID) if err != nil { return nil, err @@ -111,12 +112,29 @@ func (s *BackupService) GetBackups( return nil, errors.New("insufficient permissions to access backups for this database") } - backups, err := s.backupRepository.FindByDatabaseID(databaseID) + if limit <= 0 { + limit = 10 + } + if offset < 0 { + offset = 0 + } + + backups, err := s.backupRepository.FindByDatabaseIDWithPagination(databaseID, limit, offset) if err != nil { return nil, err } - return backups, nil + total, err := s.backupRepository.CountByDatabaseID(databaseID) + if err != nil { + return nil, err + } + + return &GetBackupsResponse{ + Backups: backups, + Total: total, + Limit: limit, + Offset: offset, + }, nil } func (s *BackupService) DeleteBackup( diff --git a/backend/internal/features/backups/config/service.go b/backend/internal/features/backups/config/service.go index 458d0a1..80d52f3 100644 --- a/backend/internal/features/backups/config/service.go +++ b/backend/internal/features/backups/config/service.go @@ -82,19 +82,6 @@ func (s *BackupConfigService) SaveBackupConfig( } } - if !backupConfig.IsBackupsEnabled && existingConfig.StorageID != nil { - if err := s.dbStorageChangeListener.OnBeforeBackupsStorageChange( - backupConfig.DatabaseID, - ); err != nil { - return nil, err - } - - // we clear storage for disabled backups to allow - // storage removal for unused storages - backupConfig.Storage = nil - backupConfig.StorageID = nil - } - return s.backupConfigRepository.Save(backupConfig) } diff --git a/backend/internal/features/databases/controller_test.go b/backend/internal/features/databases/controller_test.go index 8457eda..f67f105 100644 --- a/backend/internal/features/databases/controller_test.go +++ b/backend/internal/features/databases/controller_test.go @@ -768,3 +768,161 @@ func createTestDatabaseViaAPI( return &database } + +func Test_DatabaseSensitiveDataLifecycle_AllTypes(t *testing.T) { + testCases := []struct { + name string + databaseType DatabaseType + createDatabase func(workspaceID uuid.UUID) *Database + updateDatabase func(workspaceID uuid.UUID, databaseID uuid.UUID) *Database + verifySensitiveData func(t *testing.T, database *Database) + verifyHiddenData func(t *testing.T, database *Database) + }{ + { + name: "PostgreSQL Database", + databaseType: DatabaseTypePostgres, + createDatabase: func(workspaceID uuid.UUID) *Database { + testDbName := "test_db" + return &Database{ + WorkspaceID: &workspaceID, + Name: "Test PostgreSQL Database", + Type: DatabaseTypePostgres, + Postgresql: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion16, + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "original-password-secret", + Database: &testDbName, + }, + } + }, + updateDatabase: func(workspaceID uuid.UUID, databaseID uuid.UUID) *Database { + testDbName := "updated_test_db" + return &Database{ + ID: databaseID, + WorkspaceID: &workspaceID, + Name: "Updated PostgreSQL Database", + Type: DatabaseTypePostgres, + Postgresql: &postgresql.PostgresqlDatabase{ + Version: tools.PostgresqlVersion17, + Host: "updated-host", + Port: 5433, + Username: "updated_user", + Password: "", + Database: &testDbName, + }, + } + }, + verifySensitiveData: func(t *testing.T, database *Database) { + assert.Equal(t, "original-password-secret", database.Postgresql.Password) + }, + verifyHiddenData: func(t *testing.T, database *Database) { + assert.Equal(t, "", database.Postgresql.Password) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + // Phase 1: Create database with sensitive data + initialDatabase := tc.createDatabase(workspace.ID) + var createdDatabase Database + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/databases/create", + "Bearer "+owner.Token, + *initialDatabase, + http.StatusCreated, + &createdDatabase, + ) + assert.NotEmpty(t, createdDatabase.ID) + assert.Equal(t, initialDatabase.Name, createdDatabase.Name) + + // Phase 2: Read via service - sensitive data should be hidden + var retrievedDatabase Database + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/databases/%s", createdDatabase.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + &retrievedDatabase, + ) + tc.verifyHiddenData(t, &retrievedDatabase) + assert.Equal(t, initialDatabase.Name, retrievedDatabase.Name) + + // Phase 3: Update with non-sensitive changes only (sensitive fields empty) + updatedDatabase := tc.updateDatabase(workspace.ID, createdDatabase.ID) + var updateResponse Database + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/databases/update", + "Bearer "+owner.Token, + *updatedDatabase, + http.StatusOK, + &updateResponse, + ) + + // Phase 4: Retrieve directly from repository to verify sensitive data preservation + repository := &DatabaseRepository{} + databaseFromDB, err := repository.FindByID(createdDatabase.ID) + assert.NoError(t, err) + + // Verify original sensitive data is still present in DB + tc.verifySensitiveData(t, databaseFromDB) + + // Verify non-sensitive fields were updated in DB + assert.Equal(t, updatedDatabase.Name, databaseFromDB.Name) + + // Phase 5: Additional verification - Check via GET that data is still hidden + var finalRetrieved Database + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/databases/%s", createdDatabase.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + &finalRetrieved, + ) + tc.verifyHiddenData(t, &finalRetrieved) + + // Phase 6: Verify GetDatabasesByWorkspace also hides sensitive data + var workspaceDatabases []Database + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/databases?workspace_id=%s", workspace.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + &workspaceDatabases, + ) + var foundDatabase *Database + for i := range workspaceDatabases { + if workspaceDatabases[i].ID == createdDatabase.ID { + foundDatabase = &workspaceDatabases[i] + break + } + } + assert.NotNil(t, foundDatabase, "Database should be found in workspace databases list") + tc.verifyHiddenData(t, foundDatabase) + + // Clean up: Delete database before removing workspace + test_utils.MakeDeleteRequest( + t, + router, + fmt.Sprintf("/api/v1/databases/%s", createdDatabase.ID.String()), + "Bearer "+owner.Token, + http.StatusNoContent, + ) + + workspaces_testing.RemoveTestWorkspace(workspace, router) + }) + } +} diff --git a/backend/internal/features/databases/databases/postgresql/model.go b/backend/internal/features/databases/databases/postgresql/model.go index 4511ddf..954d0d1 100644 --- a/backend/internal/features/databases/databases/postgresql/model.go +++ b/backend/internal/features/databases/databases/postgresql/model.go @@ -66,6 +66,23 @@ func (p *PostgresqlDatabase) TestConnection(logger *slog.Logger) error { return testSingleDatabaseConnection(logger, ctx, p) } +func (p *PostgresqlDatabase) HideSensitiveData() { + p.Password = "" +} + +func (p *PostgresqlDatabase) Update(incoming *PostgresqlDatabase) { + p.Version = incoming.Version + p.Host = incoming.Host + p.Port = incoming.Port + p.Username = incoming.Username + p.Database = incoming.Database + p.IsHttps = incoming.IsHttps + + if incoming.Password != "" { + p.Password = incoming.Password + } +} + // testSingleDatabaseConnection tests connection to a specific database for pg_dump func testSingleDatabaseConnection( logger *slog.Logger, diff --git a/backend/internal/features/databases/interfaces.go b/backend/internal/features/databases/interfaces.go index f0eb7f6..21f859b 100644 --- a/backend/internal/features/databases/interfaces.go +++ b/backend/internal/features/databases/interfaces.go @@ -12,6 +12,8 @@ type DatabaseValidator interface { type DatabaseConnector interface { TestConnection(logger *slog.Logger) error + + HideSensitiveData() } type DatabaseCreationListener interface { diff --git a/backend/internal/features/databases/model.go b/backend/internal/features/databases/model.go index f21a12f..7735de4 100644 --- a/backend/internal/features/databases/model.go +++ b/backend/internal/features/databases/model.go @@ -60,6 +60,22 @@ func (d *Database) TestConnection(logger *slog.Logger) error { return d.getSpecificDatabase().TestConnection(logger) } +func (d *Database) HideSensitiveData() { + d.getSpecificDatabase().HideSensitiveData() +} + +func (d *Database) Update(incoming *Database) { + d.Name = incoming.Name + d.Type = incoming.Type + + switch d.Type { + case DatabaseTypePostgres: + if d.Postgresql != nil && incoming.Postgresql != nil { + d.Postgresql.Update(incoming.Postgresql) + } + } +} + func (d *Database) getSpecificDatabase() DatabaseConnector { switch d.Type { case DatabaseTypePostgres: diff --git a/backend/internal/features/databases/service.go b/backend/internal/features/databases/service.go index 37f8ef9..215c0ef 100644 --- a/backend/internal/features/databases/service.go +++ b/backend/internal/features/databases/service.go @@ -112,17 +112,19 @@ func (s *DatabaseService) UpdateDatabase( return err } - if err := database.Validate(); err != nil { + existingDatabase.Update(database) + + if err := existingDatabase.Validate(); err != nil { return err } - _, err = s.dbRepository.Save(database) + _, err = s.dbRepository.Save(existingDatabase) if err != nil { return err } s.auditLogService.WriteAuditLog( - fmt.Sprintf("Database updated: %s", database.Name), + fmt.Sprintf("Database updated: %s", existingDatabase.Name), &user.ID, existingDatabase.WorkspaceID, ) @@ -187,6 +189,7 @@ func (s *DatabaseService) GetDatabase( return nil, errors.New("insufficient permissions to access this database") } + database.HideSensitiveData() return database, nil } @@ -202,7 +205,16 @@ func (s *DatabaseService) GetDatabasesByWorkspace( return nil, errors.New("insufficient permissions to access this workspace") } - return s.dbRepository.FindByWorkspaceID(workspaceID) + databases, err := s.dbRepository.FindByWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + + for _, database := range databases { + database.HideSensitiveData() + } + + return databases, nil } func (s *DatabaseService) IsNotifierUsing( @@ -259,7 +271,31 @@ func (s *DatabaseService) TestDatabaseConnection( func (s *DatabaseService) TestDatabaseConnectionDirect( database *Database, ) error { - return database.TestConnection(s.logger) + var usingDatabase *Database + + if database.ID != uuid.Nil { + existingDatabase, err := s.dbRepository.FindByID(database.ID) + if err != nil { + return err + } + + if database.WorkspaceID != nil && existingDatabase.WorkspaceID != nil && + *existingDatabase.WorkspaceID != *database.WorkspaceID { + return errors.New("database does not belong to this workspace") + } + + existingDatabase.Update(database) + + if err := existingDatabase.Validate(); err != nil { + return err + } + + usingDatabase = existingDatabase + } else { + usingDatabase = database + } + + return usingDatabase.TestConnection(s.logger) } func (s *DatabaseService) GetDatabaseByID( diff --git a/backend/internal/features/notifiers/controller.go b/backend/internal/features/notifiers/controller.go index 576e483..2169867 100644 --- a/backend/internal/features/notifiers/controller.go +++ b/backend/internal/features/notifiers/controller.go @@ -54,11 +54,6 @@ func (c *NotifierController) SaveNotifier(ctx *gin.Context) { return } - if err := request.Validate(); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := c.notifierService.SaveNotifier(user, request.WorkspaceID, &request); err != nil { if err.Error() == "insufficient permissions to manage notifier in this workspace" { ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) diff --git a/backend/internal/features/notifiers/controller_test.go b/backend/internal/features/notifiers/controller_test.go index e2441af..85b09da 100644 --- a/backend/internal/features/notifiers/controller_test.go +++ b/backend/internal/features/notifiers/controller_test.go @@ -7,6 +7,10 @@ import ( "postgresus-backend/internal/config" audit_logs "postgresus-backend/internal/features/audit_logs" + discord_notifier "postgresus-backend/internal/features/notifiers/models/discord" + email_notifier "postgresus-backend/internal/features/notifiers/models/email_notifier" + slack_notifier "postgresus-backend/internal/features/notifiers/models/slack" + teams_notifier "postgresus-backend/internal/features/notifiers/models/teams" telegram_notifier "postgresus-backend/internal/features/notifiers/models/telegram" webhook_notifier "postgresus-backend/internal/features/notifiers/models/webhook" users_enums "postgresus-backend/internal/features/users/enums" @@ -512,3 +516,300 @@ func deleteNotifier( http.StatusOK, ) } + +func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) { + testCases := []struct { + name string + notifierType NotifierType + createNotifier func(workspaceID uuid.UUID) *Notifier + updateNotifier func(workspaceID uuid.UUID, notifierID uuid.UUID) *Notifier + verifySensitiveData func(t *testing.T, notifier *Notifier) + verifyHiddenData func(t *testing.T, notifier *Notifier) + }{ + { + name: "Telegram Notifier", + notifierType: NotifierTypeTelegram, + createNotifier: func(workspaceID uuid.UUID) *Notifier { + return &Notifier{ + WorkspaceID: workspaceID, + Name: "Test Telegram Notifier", + NotifierType: NotifierTypeTelegram, + TelegramNotifier: &telegram_notifier.TelegramNotifier{ + BotToken: "original-bot-token-12345", + TargetChatID: "123456789", + }, + } + }, + updateNotifier: func(workspaceID uuid.UUID, notifierID uuid.UUID) *Notifier { + return &Notifier{ + ID: notifierID, + WorkspaceID: workspaceID, + Name: "Updated Telegram Notifier", + NotifierType: NotifierTypeTelegram, + TelegramNotifier: &telegram_notifier.TelegramNotifier{ + BotToken: "", + TargetChatID: "987654321", + }, + } + }, + verifySensitiveData: func(t *testing.T, notifier *Notifier) { + assert.Equal(t, "original-bot-token-12345", notifier.TelegramNotifier.BotToken) + }, + verifyHiddenData: func(t *testing.T, notifier *Notifier) { + assert.Equal(t, "", notifier.TelegramNotifier.BotToken) + }, + }, + { + name: "Email Notifier", + notifierType: NotifierTypeEmail, + createNotifier: func(workspaceID uuid.UUID) *Notifier { + return &Notifier{ + WorkspaceID: workspaceID, + Name: "Test Email Notifier", + NotifierType: NotifierTypeEmail, + EmailNotifier: &email_notifier.EmailNotifier{ + TargetEmail: "test@example.com", + SMTPHost: "smtp.example.com", + SMTPPort: 587, + SMTPUser: "user@example.com", + SMTPPassword: "original-password-secret", + }, + } + }, + updateNotifier: func(workspaceID uuid.UUID, notifierID uuid.UUID) *Notifier { + return &Notifier{ + ID: notifierID, + WorkspaceID: workspaceID, + Name: "Updated Email Notifier", + NotifierType: NotifierTypeEmail, + EmailNotifier: &email_notifier.EmailNotifier{ + TargetEmail: "updated@example.com", + SMTPHost: "smtp.newhost.com", + SMTPPort: 465, + SMTPUser: "newuser@example.com", + SMTPPassword: "", + }, + } + }, + verifySensitiveData: func(t *testing.T, notifier *Notifier) { + assert.Equal(t, "original-password-secret", notifier.EmailNotifier.SMTPPassword) + }, + verifyHiddenData: func(t *testing.T, notifier *Notifier) { + assert.Equal(t, "", notifier.EmailNotifier.SMTPPassword) + }, + }, + { + name: "Slack Notifier", + notifierType: NotifierTypeSlack, + createNotifier: func(workspaceID uuid.UUID) *Notifier { + return &Notifier{ + WorkspaceID: workspaceID, + Name: "Test Slack Notifier", + NotifierType: NotifierTypeSlack, + SlackNotifier: &slack_notifier.SlackNotifier{ + BotToken: "xoxb-original-slack-token", + TargetChatID: "C123456", + }, + } + }, + updateNotifier: func(workspaceID uuid.UUID, notifierID uuid.UUID) *Notifier { + return &Notifier{ + ID: notifierID, + WorkspaceID: workspaceID, + Name: "Updated Slack Notifier", + NotifierType: NotifierTypeSlack, + SlackNotifier: &slack_notifier.SlackNotifier{ + BotToken: "", + TargetChatID: "C789012", + }, + } + }, + verifySensitiveData: func(t *testing.T, notifier *Notifier) { + assert.Equal(t, "xoxb-original-slack-token", notifier.SlackNotifier.BotToken) + }, + verifyHiddenData: func(t *testing.T, notifier *Notifier) { + assert.Equal(t, "", notifier.SlackNotifier.BotToken) + }, + }, + { + name: "Discord Notifier", + notifierType: NotifierTypeDiscord, + createNotifier: func(workspaceID uuid.UUID) *Notifier { + return &Notifier{ + WorkspaceID: workspaceID, + Name: "Test Discord Notifier", + NotifierType: NotifierTypeDiscord, + DiscordNotifier: &discord_notifier.DiscordNotifier{ + ChannelWebhookURL: "https://discord.com/api/webhooks/123/original-token", + }, + } + }, + updateNotifier: func(workspaceID uuid.UUID, notifierID uuid.UUID) *Notifier { + return &Notifier{ + ID: notifierID, + WorkspaceID: workspaceID, + Name: "Updated Discord Notifier", + NotifierType: NotifierTypeDiscord, + DiscordNotifier: &discord_notifier.DiscordNotifier{ + ChannelWebhookURL: "", + }, + } + }, + verifySensitiveData: func(t *testing.T, notifier *Notifier) { + assert.Equal( + t, + "https://discord.com/api/webhooks/123/original-token", + notifier.DiscordNotifier.ChannelWebhookURL, + ) + }, + verifyHiddenData: func(t *testing.T, notifier *Notifier) { + assert.Equal(t, "", notifier.DiscordNotifier.ChannelWebhookURL) + }, + }, + { + name: "Teams Notifier", + notifierType: NotifierTypeTeams, + createNotifier: func(workspaceID uuid.UUID) *Notifier { + return &Notifier{ + WorkspaceID: workspaceID, + Name: "Test Teams Notifier", + NotifierType: NotifierTypeTeams, + TeamsNotifier: &teams_notifier.TeamsNotifier{ + WebhookURL: "https://outlook.office.com/webhook/original-token", + }, + } + }, + updateNotifier: func(workspaceID uuid.UUID, notifierID uuid.UUID) *Notifier { + return &Notifier{ + ID: notifierID, + WorkspaceID: workspaceID, + Name: "Updated Teams Notifier", + NotifierType: NotifierTypeTeams, + TeamsNotifier: &teams_notifier.TeamsNotifier{ + WebhookURL: "", + }, + } + }, + verifySensitiveData: func(t *testing.T, notifier *Notifier) { + assert.Equal( + t, + "https://outlook.office.com/webhook/original-token", + notifier.TeamsNotifier.WebhookURL, + ) + }, + verifyHiddenData: func(t *testing.T, notifier *Notifier) { + assert.Equal(t, "", notifier.TeamsNotifier.WebhookURL) + }, + }, + { + name: "Webhook Notifier", + notifierType: NotifierTypeWebhook, + createNotifier: func(workspaceID uuid.UUID) *Notifier { + return &Notifier{ + WorkspaceID: workspaceID, + Name: "Test Webhook Notifier", + NotifierType: NotifierTypeWebhook, + WebhookNotifier: &webhook_notifier.WebhookNotifier{ + WebhookURL: "https://webhook.example.com/test", + WebhookMethod: webhook_notifier.WebhookMethodPOST, + }, + } + }, + updateNotifier: func(workspaceID uuid.UUID, notifierID uuid.UUID) *Notifier { + return &Notifier{ + ID: notifierID, + WorkspaceID: workspaceID, + Name: "Updated Webhook Notifier", + NotifierType: NotifierTypeWebhook, + WebhookNotifier: &webhook_notifier.WebhookNotifier{ + WebhookURL: "https://webhook.example.com/updated", + WebhookMethod: webhook_notifier.WebhookMethodGET, + }, + } + }, + verifySensitiveData: func(t *testing.T, notifier *Notifier) { + // No sensitive data to verify for webhook + }, + verifyHiddenData: func(t *testing.T, notifier *Notifier) { + // No sensitive data to hide for webhook + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + // Phase 1: Create notifier with sensitive data + initialNotifier := tc.createNotifier(workspace.ID) + var createdNotifier Notifier + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/notifiers", + "Bearer "+owner.Token, + *initialNotifier, + http.StatusOK, + &createdNotifier, + ) + assert.NotEmpty(t, createdNotifier.ID) + assert.Equal(t, initialNotifier.Name, createdNotifier.Name) + + // Phase 2: Read via service - sensitive data should be hidden + var retrievedNotifier Notifier + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/notifiers/%s", createdNotifier.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + &retrievedNotifier, + ) + tc.verifyHiddenData(t, &retrievedNotifier) + assert.Equal(t, initialNotifier.Name, retrievedNotifier.Name) + + // Phase 3: Update with non-sensitive changes only (sensitive fields empty) + updatedNotifier := tc.updateNotifier(workspace.ID, createdNotifier.ID) + var updateResponse Notifier + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/notifiers", + "Bearer "+owner.Token, + *updatedNotifier, + http.StatusOK, + &updateResponse, + ) + // Verify non-sensitive fields were updated + assert.Equal(t, updatedNotifier.Name, updateResponse.Name) + + // Phase 4: Retrieve directly from repository to verify sensitive data preservation + repository := &NotifierRepository{} + notifierFromDB, err := repository.FindByID(createdNotifier.ID) + assert.NoError(t, err) + + // Verify original sensitive data is still present in DB + tc.verifySensitiveData(t, notifierFromDB) + + // Verify non-sensitive fields were updated in DB + assert.Equal(t, updatedNotifier.Name, notifierFromDB.Name) + + // Phase 5: Additional verification - Check via GET that data is still hidden + var finalRetrieved Notifier + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/notifiers/%s", createdNotifier.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + &finalRetrieved, + ) + tc.verifyHiddenData(t, &finalRetrieved) + + deleteNotifier(t, router, createdNotifier.ID, workspace.ID, owner.Token) + workspaces_testing.RemoveTestWorkspace(workspace, router) + }) + } +} diff --git a/backend/internal/features/notifiers/interfaces.go b/backend/internal/features/notifiers/interfaces.go index f1dfa39..f5ff00f 100644 --- a/backend/internal/features/notifiers/interfaces.go +++ b/backend/internal/features/notifiers/interfaces.go @@ -6,4 +6,6 @@ type NotificationSender interface { Send(logger *slog.Logger, heading string, message string) error Validate() error + + HideSensitiveData() } diff --git a/backend/internal/features/notifiers/model.go b/backend/internal/features/notifiers/model.go index 0145790..5db5b30 100644 --- a/backend/internal/features/notifiers/model.go +++ b/backend/internal/features/notifiers/model.go @@ -54,6 +54,42 @@ func (n *Notifier) Send(logger *slog.Logger, heading string, message string) err return err } +func (n *Notifier) HideSensitiveData() { + n.getSpecificNotifier().HideSensitiveData() +} + +func (n *Notifier) Update(incoming *Notifier) { + n.Name = incoming.Name + n.NotifierType = incoming.NotifierType + + switch n.NotifierType { + case NotifierTypeTelegram: + if n.TelegramNotifier != nil && incoming.TelegramNotifier != nil { + n.TelegramNotifier.Update(incoming.TelegramNotifier) + } + case NotifierTypeEmail: + if n.EmailNotifier != nil && incoming.EmailNotifier != nil { + n.EmailNotifier.Update(incoming.EmailNotifier) + } + case NotifierTypeWebhook: + if n.WebhookNotifier != nil && incoming.WebhookNotifier != nil { + n.WebhookNotifier.Update(incoming.WebhookNotifier) + } + case NotifierTypeSlack: + if n.SlackNotifier != nil && incoming.SlackNotifier != nil { + n.SlackNotifier.Update(incoming.SlackNotifier) + } + case NotifierTypeDiscord: + if n.DiscordNotifier != nil && incoming.DiscordNotifier != nil { + n.DiscordNotifier.Update(incoming.DiscordNotifier) + } + case NotifierTypeTeams: + if n.TeamsNotifier != nil && incoming.TeamsNotifier != nil { + n.TeamsNotifier.Update(incoming.TeamsNotifier) + } + } +} + func (n *Notifier) getSpecificNotifier() NotificationSender { switch n.NotifierType { case NotifierTypeTelegram: diff --git a/backend/internal/features/notifiers/models/discord/model.go b/backend/internal/features/notifiers/models/discord/model.go index 6ebecb9..85b1c23 100644 --- a/backend/internal/features/notifiers/models/discord/model.go +++ b/backend/internal/features/notifiers/models/discord/model.go @@ -71,3 +71,13 @@ func (d *DiscordNotifier) Send(logger *slog.Logger, heading string, message stri return nil } + +func (d *DiscordNotifier) HideSensitiveData() { + d.ChannelWebhookURL = "" +} + +func (d *DiscordNotifier) Update(incoming *DiscordNotifier) { + if incoming.ChannelWebhookURL != "" { + d.ChannelWebhookURL = incoming.ChannelWebhookURL + } +} diff --git a/backend/internal/features/notifiers/models/email_notifier/model.go b/backend/internal/features/notifiers/models/email_notifier/model.go index 3dde579..689ff10 100644 --- a/backend/internal/features/notifiers/models/email_notifier/model.go +++ b/backend/internal/features/notifiers/models/email_notifier/model.go @@ -208,3 +208,18 @@ func (e *EmailNotifier) Send(logger *slog.Logger, heading string, message string return client.Quit() } } + +func (e *EmailNotifier) HideSensitiveData() { + e.SMTPPassword = "" +} + +func (e *EmailNotifier) Update(incoming *EmailNotifier) { + e.TargetEmail = incoming.TargetEmail + e.SMTPHost = incoming.SMTPHost + e.SMTPPort = incoming.SMTPPort + e.SMTPUser = incoming.SMTPUser + + if incoming.SMTPPassword != "" { + e.SMTPPassword = incoming.SMTPPassword + } +} diff --git a/backend/internal/features/notifiers/models/slack/model.go b/backend/internal/features/notifiers/models/slack/model.go index a743a7b..cefbcf3 100644 --- a/backend/internal/features/notifiers/models/slack/model.go +++ b/backend/internal/features/notifiers/models/slack/model.go @@ -132,3 +132,15 @@ func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error return nil } } + +func (s *SlackNotifier) HideSensitiveData() { + s.BotToken = "" +} + +func (s *SlackNotifier) Update(incoming *SlackNotifier) { + s.TargetChatID = incoming.TargetChatID + + if incoming.BotToken != "" { + s.BotToken = incoming.BotToken + } +} diff --git a/backend/internal/features/notifiers/models/teams/model.go b/backend/internal/features/notifiers/models/teams/model.go index 88287b2..e3f3598 100644 --- a/backend/internal/features/notifiers/models/teams/model.go +++ b/backend/internal/features/notifiers/models/teams/model.go @@ -94,3 +94,13 @@ func (n *TeamsNotifier) Send(logger *slog.Logger, heading, message string) error return nil } + +func (n *TeamsNotifier) HideSensitiveData() { + n.WebhookURL = "" +} + +func (n *TeamsNotifier) Update(incoming *TeamsNotifier) { + if incoming.WebhookURL != "" { + n.WebhookURL = incoming.WebhookURL + } +} diff --git a/backend/internal/features/notifiers/models/telegram/model.go b/backend/internal/features/notifiers/models/telegram/model.go index 5611407..5eca323 100644 --- a/backend/internal/features/notifiers/models/telegram/model.go +++ b/backend/internal/features/notifiers/models/telegram/model.go @@ -80,3 +80,16 @@ func (t *TelegramNotifier) Send(logger *slog.Logger, heading string, message str return nil } + +func (t *TelegramNotifier) HideSensitiveData() { + t.BotToken = "" +} + +func (t *TelegramNotifier) Update(incoming *TelegramNotifier) { + t.TargetChatID = incoming.TargetChatID + t.ThreadID = incoming.ThreadID + + if incoming.BotToken != "" { + t.BotToken = incoming.BotToken + } +} diff --git a/backend/internal/features/notifiers/models/webhook/model.go b/backend/internal/features/notifiers/models/webhook/model.go index a7d19ba..eefffd0 100644 --- a/backend/internal/features/notifiers/models/webhook/model.go +++ b/backend/internal/features/notifiers/models/webhook/model.go @@ -102,3 +102,11 @@ func (t *WebhookNotifier) Send(logger *slog.Logger, heading string, message stri return fmt.Errorf("unsupported webhook method: %s", t.WebhookMethod) } } + +func (t *WebhookNotifier) HideSensitiveData() { +} + +func (t *WebhookNotifier) Update(incoming *WebhookNotifier) { + t.WebhookURL = incoming.WebhookURL + t.WebhookMethod = incoming.WebhookMethod +} diff --git a/backend/internal/features/notifiers/service.go b/backend/internal/features/notifiers/service.go index 1f7f54a..62b0249 100644 --- a/backend/internal/features/notifiers/service.go +++ b/backend/internal/features/notifiers/service.go @@ -44,23 +44,34 @@ func (s *NotifierService) SaveNotifier( return errors.New("notifier does not belong to this workspace") } - notifier.WorkspaceID = existingNotifier.WorkspaceID - } else { - notifier.WorkspaceID = workspaceID - } + existingNotifier.Update(notifier) - _, err = s.notifierRepository.Save(notifier) - if err != nil { - return err - } + if err := existingNotifier.Validate(); err != nil { + return err + } + + _, err = s.notifierRepository.Save(existingNotifier) + if err != nil { + return err + } - if isUpdate { s.auditLogService.WriteAuditLog( - fmt.Sprintf("Notifier updated: %s", notifier.Name), + fmt.Sprintf("Notifier updated: %s", existingNotifier.Name), &user.ID, &workspaceID, ) } else { + notifier.WorkspaceID = workspaceID + + if err := notifier.Validate(); err != nil { + return err + } + + _, err = s.notifierRepository.Save(notifier) + if err != nil { + return err + } + s.auditLogService.WriteAuditLog( fmt.Sprintf("Notifier created: %s", notifier.Name), &user.ID, @@ -119,6 +130,7 @@ func (s *NotifierService) GetNotifier( return nil, errors.New("insufficient permissions to view notifier in this workspace") } + notifier.HideSensitiveData() return notifier, nil } @@ -134,7 +146,16 @@ func (s *NotifierService) GetNotifiers( return nil, errors.New("insufficient permissions to view notifiers in this workspace") } - return s.notifierRepository.FindByWorkspaceID(workspaceID) + notifiers, err := s.notifierRepository.FindByWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + + for _, notifier := range notifiers { + notifier.HideSensitiveData() + } + + return notifiers, nil } func (s *NotifierService) SendTestNotification( @@ -170,7 +191,30 @@ func (s *NotifierService) SendTestNotification( func (s *NotifierService) SendTestNotificationToNotifier( notifier *Notifier, ) error { - return notifier.Send(s.logger, "Test message", "This is a test message") + var usingNotifier *Notifier + + if notifier.ID != uuid.Nil { + existingNotifier, err := s.notifierRepository.FindByID(notifier.ID) + if err != nil { + return err + } + + if existingNotifier.WorkspaceID != notifier.WorkspaceID { + return errors.New("notifier does not belong to this workspace") + } + + existingNotifier.Update(notifier) + + if err := existingNotifier.Validate(); err != nil { + return err + } + + usingNotifier = existingNotifier + } else { + usingNotifier = notifier + } + + return usingNotifier.Send(s.logger, "Test message", "This is a test message") } func (s *NotifierService) SendNotification( diff --git a/backend/internal/features/storages/controller.go b/backend/internal/features/storages/controller.go index 998106d..f32b645 100644 --- a/backend/internal/features/storages/controller.go +++ b/backend/internal/features/storages/controller.go @@ -54,11 +54,6 @@ func (c *StorageController) SaveStorage(ctx *gin.Context) { return } - if err := request.Validate(); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := c.storageService.SaveStorage(user, request.WorkspaceID, &request); err != nil { if err.Error() == "insufficient permissions to manage storage in this workspace" { ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) @@ -271,11 +266,6 @@ func (c *StorageController) TestStorageConnectionDirect(ctx *gin.Context) { return } - if err := request.Validate(); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := c.storageService.TestStorageConnectionDirect(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/backend/internal/features/storages/controller_test.go b/backend/internal/features/storages/controller_test.go index e6fb8b9..58ab351 100644 --- a/backend/internal/features/storages/controller_test.go +++ b/backend/internal/features/storages/controller_test.go @@ -7,6 +7,7 @@ import ( audit_logs "postgresus-backend/internal/features/audit_logs" local_storage "postgresus-backend/internal/features/storages/models/local" + s3_storage "postgresus-backend/internal/features/storages/models/s3" users_enums "postgresus-backend/internal/features/users/enums" users_middleware "postgresus-backend/internal/features/users/middleware" users_services "postgresus-backend/internal/features/users/services" @@ -484,3 +485,158 @@ func deleteStorage( http.StatusOK, ) } + +func Test_StorageSensitiveDataLifecycle_AllTypes(t *testing.T) { + testCases := []struct { + name string + storageType StorageType + createStorage func(workspaceID uuid.UUID) *Storage + updateStorage func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage + verifySensitiveData func(t *testing.T, storage *Storage) + verifyHiddenData func(t *testing.T, storage *Storage) + }{ + { + name: "S3 Storage", + storageType: StorageTypeS3, + createStorage: func(workspaceID uuid.UUID) *Storage { + return &Storage{ + WorkspaceID: workspaceID, + Type: StorageTypeS3, + Name: "Test S3 Storage", + S3Storage: &s3_storage.S3Storage{ + S3Bucket: "test-bucket", + S3Region: "us-east-1", + S3AccessKey: "original-access-key", + S3SecretKey: "original-secret-key", + S3Endpoint: "https://s3.amazonaws.com", + }, + } + }, + updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage { + return &Storage{ + ID: storageID, + WorkspaceID: workspaceID, + Type: StorageTypeS3, + Name: "Updated S3 Storage", + S3Storage: &s3_storage.S3Storage{ + S3Bucket: "updated-bucket", + S3Region: "us-west-2", + S3AccessKey: "", + S3SecretKey: "", + S3Endpoint: "https://s3.us-west-2.amazonaws.com", + }, + } + }, + verifySensitiveData: func(t *testing.T, storage *Storage) { + assert.Equal(t, "original-access-key", storage.S3Storage.S3AccessKey) + assert.Equal(t, "original-secret-key", storage.S3Storage.S3SecretKey) + }, + verifyHiddenData: func(t *testing.T, storage *Storage) { + assert.Equal(t, "", storage.S3Storage.S3AccessKey) + assert.Equal(t, "", storage.S3Storage.S3SecretKey) + }, + }, + { + name: "Local Storage", + storageType: StorageTypeLocal, + createStorage: func(workspaceID uuid.UUID) *Storage { + return &Storage{ + WorkspaceID: workspaceID, + Type: StorageTypeLocal, + Name: "Test Local Storage", + LocalStorage: &local_storage.LocalStorage{}, + } + }, + updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage { + return &Storage{ + ID: storageID, + WorkspaceID: workspaceID, + Type: StorageTypeLocal, + Name: "Updated Local Storage", + LocalStorage: &local_storage.LocalStorage{}, + } + }, + verifySensitiveData: func(t *testing.T, storage *Storage) { + }, + verifyHiddenData: func(t *testing.T, storage *Storage) { + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + router := createRouter() + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + // Phase 1: Create storage with sensitive data + initialStorage := tc.createStorage(workspace.ID) + var createdStorage Storage + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/storages", + "Bearer "+owner.Token, + *initialStorage, + http.StatusOK, + &createdStorage, + ) + + assert.NotEmpty(t, createdStorage.ID) + assert.Equal(t, initialStorage.Name, createdStorage.Name) + + // Phase 2: Read via service - sensitive data should be hidden + var retrievedStorage Storage + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/storages/%s", createdStorage.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + &retrievedStorage, + ) + + tc.verifyHiddenData(t, &retrievedStorage) + assert.Equal(t, initialStorage.Name, retrievedStorage.Name) + + // Phase 3: Update with non-sensitive changes only (sensitive fields empty) + updatedStorage := tc.updateStorage(workspace.ID, createdStorage.ID) + var updateResponse Storage + test_utils.MakePostRequestAndUnmarshal( + t, + router, + "/api/v1/storages", + "Bearer "+owner.Token, + *updatedStorage, + http.StatusOK, + &updateResponse, + ) + + // Verify non-sensitive fields were updated + assert.Equal(t, updatedStorage.Name, updateResponse.Name) + + // Phase 4: Retrieve directly from repository to verify sensitive data preservation + repository := &StorageRepository{} + storageFromDB, err := repository.FindByID(createdStorage.ID) + assert.NoError(t, err) + + // Verify original sensitive data is still present in DB + tc.verifySensitiveData(t, storageFromDB) + + // Verify non-sensitive fields were updated in DB + assert.Equal(t, updatedStorage.Name, storageFromDB.Name) + + // Additional verification: Check via GET that data is still hidden + var finalRetrieved Storage + test_utils.MakeGetRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/storages/%s", createdStorage.ID.String()), + "Bearer "+owner.Token, + http.StatusOK, + &finalRetrieved, + ) + tc.verifyHiddenData(t, &finalRetrieved) + }) + } +} diff --git a/backend/internal/features/storages/interfaces.go b/backend/internal/features/storages/interfaces.go index e2f32a9..25e1f94 100644 --- a/backend/internal/features/storages/interfaces.go +++ b/backend/internal/features/storages/interfaces.go @@ -17,4 +17,6 @@ type StorageFileSaver interface { Validate() error TestConnection() error + + HideSensitiveData() } diff --git a/backend/internal/features/storages/model.go b/backend/internal/features/storages/model.go index a7c5105..c422999 100644 --- a/backend/internal/features/storages/model.go +++ b/backend/internal/features/storages/model.go @@ -63,6 +63,34 @@ func (s *Storage) TestConnection() error { return s.getSpecificStorage().TestConnection() } +func (s *Storage) HideSensitiveData() { + s.getSpecificStorage().HideSensitiveData() +} + +func (s *Storage) Update(incoming *Storage) { + s.Name = incoming.Name + s.Type = incoming.Type + + switch s.Type { + case StorageTypeLocal: + if s.LocalStorage != nil && incoming.LocalStorage != nil { + s.LocalStorage.Update(incoming.LocalStorage) + } + case StorageTypeS3: + if s.S3Storage != nil && incoming.S3Storage != nil { + s.S3Storage.Update(incoming.S3Storage) + } + case StorageTypeGoogleDrive: + if s.GoogleDriveStorage != nil && incoming.GoogleDriveStorage != nil { + s.GoogleDriveStorage.Update(incoming.GoogleDriveStorage) + } + case StorageTypeNAS: + if s.NASStorage != nil && incoming.NASStorage != nil { + s.NASStorage.Update(incoming.NASStorage) + } + } +} + func (s *Storage) getSpecificStorage() StorageFileSaver { switch s.Type { case StorageTypeLocal: diff --git a/backend/internal/features/storages/models/google_drive/model.go b/backend/internal/features/storages/models/google_drive/model.go index b0c3ad1..0ff16e5 100644 --- a/backend/internal/features/storages/models/google_drive/model.go +++ b/backend/internal/features/storages/models/google_drive/model.go @@ -191,6 +191,23 @@ func (s *GoogleDriveStorage) TestConnection() error { }) } +func (s *GoogleDriveStorage) HideSensitiveData() { + s.ClientSecret = "" + s.TokenJSON = "" +} + +func (s *GoogleDriveStorage) Update(incoming *GoogleDriveStorage) { + s.ClientID = incoming.ClientID + + if incoming.ClientSecret != "" { + s.ClientSecret = incoming.ClientSecret + } + + if incoming.TokenJSON != "" { + s.TokenJSON = incoming.TokenJSON + } +} + // withRetryOnAuth executes the provided function with retry logic for authentication errors func (s *GoogleDriveStorage) withRetryOnAuth(fn func(*drive.Service) error) error { driveService, err := s.getDriveService() diff --git a/backend/internal/features/storages/models/local/model.go b/backend/internal/features/storages/models/local/model.go index c26659c..4efe3f3 100644 --- a/backend/internal/features/storages/models/local/model.go +++ b/backend/internal/features/storages/models/local/model.go @@ -156,3 +156,9 @@ func (l *LocalStorage) TestConnection() error { return nil } + +func (l *LocalStorage) HideSensitiveData() { +} + +func (l *LocalStorage) Update(incoming *LocalStorage) { +} diff --git a/backend/internal/features/storages/models/nas/model.go b/backend/internal/features/storages/models/nas/model.go index abb3d57..70e025c 100644 --- a/backend/internal/features/storages/models/nas/model.go +++ b/backend/internal/features/storages/models/nas/model.go @@ -251,6 +251,24 @@ func (n *NASStorage) TestConnection() error { return nil } +func (n *NASStorage) HideSensitiveData() { + n.Password = "" +} + +func (n *NASStorage) Update(incoming *NASStorage) { + n.Host = incoming.Host + n.Port = incoming.Port + n.Share = incoming.Share + n.Username = incoming.Username + n.UseSSL = incoming.UseSSL + n.Domain = incoming.Domain + n.Path = incoming.Path + + if incoming.Password != "" { + n.Password = incoming.Password + } +} + func (n *NASStorage) createSession() (*smb2.Session, error) { // Create connection with timeout conn, err := n.createConnection() diff --git a/backend/internal/features/storages/models/s3/model.go b/backend/internal/features/storages/models/s3/model.go index b1e6b9b..65a6d84 100644 --- a/backend/internal/features/storages/models/s3/model.go +++ b/backend/internal/features/storages/models/s3/model.go @@ -180,6 +180,25 @@ func (s *S3Storage) TestConnection() error { return nil } +func (s *S3Storage) HideSensitiveData() { + s.S3AccessKey = "" + s.S3SecretKey = "" +} + +func (s *S3Storage) Update(incoming *S3Storage) { + s.S3Bucket = incoming.S3Bucket + s.S3Region = incoming.S3Region + s.S3Endpoint = incoming.S3Endpoint + + if incoming.S3AccessKey != "" { + s.S3AccessKey = incoming.S3AccessKey + } + + if incoming.S3SecretKey != "" { + s.S3SecretKey = incoming.S3SecretKey + } +} + func (s *S3Storage) getClient() (*minio.Client, error) { endpoint := s.S3Endpoint useSSL := true diff --git a/backend/internal/features/storages/service.go b/backend/internal/features/storages/service.go index 396db4b..7bd84c8 100644 --- a/backend/internal/features/storages/service.go +++ b/backend/internal/features/storages/service.go @@ -42,23 +42,34 @@ func (s *StorageService) SaveStorage( return errors.New("storage does not belong to this workspace") } - storage.WorkspaceID = existingStorage.WorkspaceID - } else { - storage.WorkspaceID = workspaceID - } + existingStorage.Update(storage) - _, err = s.storageRepository.Save(storage) - if err != nil { - return err - } + if err := existingStorage.Validate(); err != nil { + return err + } + + _, err = s.storageRepository.Save(existingStorage) + if err != nil { + return err + } - if isUpdate { s.auditLogService.WriteAuditLog( - fmt.Sprintf("Storage updated: %s", storage.Name), + fmt.Sprintf("Storage updated: %s", existingStorage.Name), &user.ID, &workspaceID, ) } else { + storage.WorkspaceID = workspaceID + + if err := storage.Validate(); err != nil { + return err + } + + _, err = s.storageRepository.Save(storage) + if err != nil { + return err + } + s.auditLogService.WriteAuditLog( fmt.Sprintf("Storage created: %s", storage.Name), &user.ID, @@ -117,6 +128,8 @@ func (s *StorageService) GetStorage( return nil, errors.New("insufficient permissions to view storage in this workspace") } + storage.HideSensitiveData() + return storage, nil } @@ -132,7 +145,16 @@ func (s *StorageService) GetStorages( return nil, errors.New("insufficient permissions to view storages in this workspace") } - return s.storageRepository.FindByWorkspaceID(workspaceID) + storages, err := s.storageRepository.FindByWorkspaceID(workspaceID) + if err != nil { + return nil, err + } + + for _, storage := range storages { + storage.HideSensitiveData() + } + + return storages, nil } func (s *StorageService) TestStorageConnection( @@ -171,7 +193,30 @@ func (s *StorageService) TestStorageConnection( func (s *StorageService) TestStorageConnectionDirect( storage *Storage, ) error { - return storage.TestConnection() + var usingStorage *Storage + + if storage.ID != uuid.Nil { + existingStorage, err := s.storageRepository.FindByID(storage.ID) + if err != nil { + return err + } + + if existingStorage.WorkspaceID != storage.WorkspaceID { + return errors.New("storage does not belong to this workspace") + } + + existingStorage.Update(storage) + + if err := existingStorage.Validate(); err != nil { + return err + } + + usingStorage = existingStorage + } else { + usingStorage = storage + } + + return usingStorage.TestConnection() } func (s *StorageService) GetStorageByID( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4afa6b3..0e3c02f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { Routes } from 'react-router'; import { userApi } from './entity/users'; import { AuthPageComponent } from './pages/AuthPageComponent'; import { OAuthCallbackPage } from './pages/OAuthCallbackPage'; +import { OauthStorageComponent } from './pages/OauthStorageComponent'; import { MainScreenComponent } from './widgets/main/MainScreenComponent'; function App() { @@ -32,6 +33,7 @@ function App() { } /> + } /> : } diff --git a/frontend/src/entity/backups/api/backupsApi.ts b/frontend/src/entity/backups/api/backupsApi.ts index 4ce3271..957b5f0 100644 --- a/frontend/src/entity/backups/api/backupsApi.ts +++ b/frontend/src/entity/backups/api/backupsApi.ts @@ -1,12 +1,16 @@ import { getApplicationServer } from '../../../constants'; import RequestOptions from '../../../shared/api/RequestOptions'; import { apiHelper } from '../../../shared/api/apiHelper'; -import type { Backup } from '../model/Backup'; +import type { GetBackupsResponse } from '../model/GetBackupsResponse'; export const backupsApi = { - async getBackups(databaseId: string) { - return apiHelper.fetchGetJson( - `${getApplicationServer()}/api/v1/backups?database_id=${databaseId}`, + async getBackups(databaseId: string, limit?: number, offset?: number) { + const params = new URLSearchParams({ database_id: databaseId }); + if (limit !== undefined) params.append('limit', limit.toString()); + if (offset !== undefined) params.append('offset', offset.toString()); + + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/backups?${params.toString()}`, undefined, true, ); diff --git a/frontend/src/entity/backups/model/GetBackupsResponse.ts b/frontend/src/entity/backups/model/GetBackupsResponse.ts new file mode 100644 index 0000000..5b67384 --- /dev/null +++ b/frontend/src/entity/backups/model/GetBackupsResponse.ts @@ -0,0 +1,8 @@ +import type { Backup } from './Backup'; + +export interface GetBackupsResponse { + backups: Backup[]; + total: number; + limit: number; + offset: number; +} diff --git a/frontend/src/entity/notifiers/models/discord/validateDiscordNotifier.ts b/frontend/src/entity/notifiers/models/discord/validateDiscordNotifier.ts index b9bdb7e..2968460 100644 --- a/frontend/src/entity/notifiers/models/discord/validateDiscordNotifier.ts +++ b/frontend/src/entity/notifiers/models/discord/validateDiscordNotifier.ts @@ -1,7 +1,7 @@ import type { DiscordNotifier } from './DiscordNotifier'; -export const validateDiscordNotifier = (notifier: DiscordNotifier): boolean => { - if (!notifier.channelWebhookUrl) { +export const validateDiscordNotifier = (isCreate: boolean, notifier: DiscordNotifier): boolean => { + if (isCreate && !notifier.channelWebhookUrl) { return false; } diff --git a/frontend/src/entity/notifiers/models/email/validateEmailNotifier.ts b/frontend/src/entity/notifiers/models/email/validateEmailNotifier.ts index b4e9993..9d6c3f7 100644 --- a/frontend/src/entity/notifiers/models/email/validateEmailNotifier.ts +++ b/frontend/src/entity/notifiers/models/email/validateEmailNotifier.ts @@ -1,6 +1,6 @@ import type { EmailNotifier } from './EmailNotifier'; -export const validateEmailNotifier = (notifier: EmailNotifier): boolean => { +export const validateEmailNotifier = (isCreate: boolean, notifier: EmailNotifier): boolean => { if (!notifier.targetEmail) { return false; } @@ -13,5 +13,9 @@ export const validateEmailNotifier = (notifier: EmailNotifier): boolean => { return false; } + if (isCreate && !notifier.smtpPassword) { + return false; + } + return true; }; diff --git a/frontend/src/entity/notifiers/models/slack/validateSlackNotifier.ts b/frontend/src/entity/notifiers/models/slack/validateSlackNotifier.ts index 63665d8..fefe22a 100644 --- a/frontend/src/entity/notifiers/models/slack/validateSlackNotifier.ts +++ b/frontend/src/entity/notifiers/models/slack/validateSlackNotifier.ts @@ -1,7 +1,7 @@ import type { SlackNotifier } from './SlackNotifier'; -export const validateSlackNotifier = (notifier: SlackNotifier): boolean => { - if (!notifier.botToken) { +export const validateSlackNotifier = (isCreate: boolean, notifier: SlackNotifier): boolean => { + if (isCreate && !notifier.botToken) { return false; } diff --git a/frontend/src/entity/notifiers/models/teams/validateTeamsNotifier.ts b/frontend/src/entity/notifiers/models/teams/validateTeamsNotifier.ts index 28cac0f..4a1656e 100644 --- a/frontend/src/entity/notifiers/models/teams/validateTeamsNotifier.ts +++ b/frontend/src/entity/notifiers/models/teams/validateTeamsNotifier.ts @@ -1,7 +1,7 @@ import type { TeamsNotifier } from './TeamsNotifier'; -export const validateTeamsNotifier = (notifier: TeamsNotifier): boolean => { - if (!notifier?.powerAutomateUrl) { +export const validateTeamsNotifier = (isCreate: boolean, notifier: TeamsNotifier): boolean => { + if (isCreate && !notifier?.powerAutomateUrl) { return false; } diff --git a/frontend/src/entity/notifiers/models/telegram/validateTelegramNotifier.ts b/frontend/src/entity/notifiers/models/telegram/validateTelegramNotifier.ts index f827bd8..2a1a2d3 100644 --- a/frontend/src/entity/notifiers/models/telegram/validateTelegramNotifier.ts +++ b/frontend/src/entity/notifiers/models/telegram/validateTelegramNotifier.ts @@ -1,7 +1,10 @@ import type { TelegramNotifier } from './TelegramNotifier'; -export const validateTelegramNotifier = (notifier: TelegramNotifier): boolean => { - if (!notifier.botToken) { +export const validateTelegramNotifier = ( + isCreate: boolean, + notifier: TelegramNotifier, +): boolean => { + if (isCreate && !notifier.botToken) { return false; } diff --git a/frontend/src/entity/notifiers/models/webhook/validateWebhookNotifier.ts b/frontend/src/entity/notifiers/models/webhook/validateWebhookNotifier.ts index 5d71f74..a1429b3 100644 --- a/frontend/src/entity/notifiers/models/webhook/validateWebhookNotifier.ts +++ b/frontend/src/entity/notifiers/models/webhook/validateWebhookNotifier.ts @@ -1,7 +1,7 @@ import type { WebhookNotifier } from './WebhookNotifier'; -export const validateWebhookNotifier = (notifier: WebhookNotifier): boolean => { - if (!notifier.webhookUrl) { +export const validateWebhookNotifier = (isCreate: boolean, notifier: WebhookNotifier): boolean => { + if (isCreate && !notifier.webhookUrl) { return false; } diff --git a/frontend/src/features/backups/ui/BackupsComponent.tsx b/frontend/src/features/backups/ui/BackupsComponent.tsx index e51528c..19b4f41 100644 --- a/frontend/src/features/backups/ui/BackupsComponent.tsx +++ b/frontend/src/features/backups/ui/BackupsComponent.tsx @@ -12,23 +12,37 @@ import type { ColumnsType } from 'antd/es/table'; import dayjs from 'dayjs'; import { useEffect, useRef, useState } from 'react'; -import { type Backup, BackupStatus, backupConfigApi, backupsApi } from '../../../entity/backups'; +import { + type Backup, + type BackupConfig, + BackupStatus, + backupConfigApi, + backupsApi, +} from '../../../entity/backups'; import type { Database } from '../../../entity/databases'; import { getUserTimeFormat } from '../../../shared/time'; import { ConfirmationComponent } from '../../../shared/ui'; import { RestoresComponent } from '../../restores'; +const BACKUPS_PAGE_SIZE = 10; + interface Props { database: Database; isCanManageDBs: boolean; + scrollContainerRef?: React.RefObject; } -export const BackupsComponent = ({ database, isCanManageDBs }: Props) => { +export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef }: Props) => { const [isBackupsLoading, setIsBackupsLoading] = useState(false); const [backups, setBackups] = useState([]); + const [totalBackups, setTotalBackups] = useState(0); + const [currentLimit, setCurrentLimit] = useState(BACKUPS_PAGE_SIZE); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + + const [backupConfig, setBackupConfig] = useState(); const [isBackupConfigLoading, setIsBackupConfigLoading] = useState(false); - const [isShowBackupConfig, setIsShowBackupConfig] = useState(false); const [isMakeBackupRequestLoading, setIsMakeBackupRequestLoading] = useState(false); @@ -40,6 +54,7 @@ export const BackupsComponent = ({ database, isCanManageDBs }: Props) => { const [showingRestoresBackupId, setShowingRestoresBackupId] = useState(); const isReloadInProgress = useRef(false); + const isLazyLoadInProgress = useRef(false); const [downloadingBackupId, setDownloadingBackupId] = useState(); @@ -71,16 +86,20 @@ export const BackupsComponent = ({ database, isCanManageDBs }: Props) => { } }; - const loadBackups = async () => { - if (isReloadInProgress.current) { + const loadBackups = async (limit?: number) => { + if (isReloadInProgress.current || isLazyLoadInProgress.current) { return; } isReloadInProgress.current = true; try { - const backups = await backupsApi.getBackups(database.id); - setBackups(backups); + const loadLimit = limit || currentLimit; + const response = await backupsApi.getBackups(database.id, loadLimit, 0); + + setBackups(response.backups); + setTotalBackups(response.total); + setHasMore(response.backups.length < response.total); } catch (e) { alert((e as Error).message); } @@ -88,12 +107,75 @@ export const BackupsComponent = ({ database, isCanManageDBs }: Props) => { isReloadInProgress.current = false; }; + const reloadInProgressBackups = async () => { + if (isReloadInProgress.current || isLazyLoadInProgress.current) { + return; + } + + isReloadInProgress.current = true; + + try { + // Fetch only the recent backups that could be in progress + // We fetch a small number (20) to capture recent backups that might be in progress + const response = await backupsApi.getBackups(database.id, 20, 0); + + // Update only the backups that exist in both lists + setBackups((prevBackups) => { + const updatedBackups = [...prevBackups]; + + response.backups.forEach((newBackup) => { + const index = updatedBackups.findIndex((b) => b.id === newBackup.id); + if (index !== -1) { + updatedBackups[index] = newBackup; + } else if (index === -1 && updatedBackups.length < currentLimit) { + // New backup that doesn't exist yet (e.g., just created) + updatedBackups.unshift(newBackup); + } + }); + + return updatedBackups; + }); + + setTotalBackups(response.total); + } catch (e) { + alert((e as Error).message); + } + + isReloadInProgress.current = false; + }; + + const loadMoreBackups = async () => { + if (isLoadingMore || !hasMore || isLazyLoadInProgress.current) { + return; + } + + isLazyLoadInProgress.current = true; + setIsLoadingMore(true); + + try { + const newLimit = currentLimit + BACKUPS_PAGE_SIZE; + const response = await backupsApi.getBackups(database.id, newLimit, 0); + + setBackups(response.backups); + setCurrentLimit(newLimit); + setTotalBackups(response.total); + setHasMore(response.backups.length < response.total); + } catch (e) { + alert((e as Error).message); + } + + setIsLoadingMore(false); + isLazyLoadInProgress.current = false; + }; + const makeBackup = async () => { setIsMakeBackupRequestLoading(true); try { await backupsApi.makeBackup(database.id); - await loadBackups(); + setCurrentLimit(BACKUPS_PAGE_SIZE); + setHasMore(true); + await loadBackups(BACKUPS_PAGE_SIZE); } catch (e) { alert((e as Error).message); } @@ -111,7 +193,9 @@ export const BackupsComponent = ({ database, isCanManageDBs }: Props) => { try { await backupsApi.deleteBackup(deleteConfimationId); - await loadBackups(); + setCurrentLimit(BACKUPS_PAGE_SIZE); + setHasMore(true); + await loadBackups(BACKUPS_PAGE_SIZE); } catch (e) { alert((e as Error).message); } @@ -121,30 +205,37 @@ export const BackupsComponent = ({ database, isCanManageDBs }: Props) => { }; useEffect(() => { - let isBackupsEnabled = false; - setIsBackupConfigLoading(true); - backupConfigApi.getBackupConfigByDbID(database.id).then((backupConfig) => { + setCurrentLimit(BACKUPS_PAGE_SIZE); + setHasMore(true); + + backupConfigApi.getBackupConfigByDbID(database.id).then((config) => { + setBackupConfig(config); setIsBackupConfigLoading(false); - if (backupConfig.isBackupsEnabled) { - // load backups - isBackupsEnabled = true; - setIsShowBackupConfig(true); - - setIsBackupsLoading(true); - loadBackups().then(() => setIsBackupsLoading(false)); - } + setIsBackupsLoading(true); + loadBackups(BACKUPS_PAGE_SIZE).then(() => setIsBackupsLoading(false)); }); - const interval = setInterval(() => { - if (isBackupsEnabled) { - loadBackups(); - } + return () => {}; + }, [database]); + + // Reload backups that are in progress to update their state + useEffect(() => { + const hasInProgressBackups = backups.some( + (backup) => backup.status === BackupStatus.IN_PROGRESS, + ); + + if (!hasInProgressBackups) { + return; + } + + const timeoutId = setTimeout(async () => { + await reloadInProgressBackups(); }, 1_000); - return () => clearInterval(interval); - }, [database]); + return () => clearTimeout(timeoutId); + }, [backups]); useEffect(() => { if (downloadingBackupId) { @@ -152,6 +243,26 @@ export const BackupsComponent = ({ database, isCanManageDBs }: Props) => { } }, [downloadingBackupId]); + useEffect(() => { + if (!scrollContainerRef?.current) { + return; + } + + const handleScroll = () => { + if (!scrollContainerRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + + if (scrollHeight - scrollTop <= clientHeight + 100 && hasMore && !isLoadingMore) { + loadMoreBackups(); + } + }; + + const container = scrollContainerRef.current; + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, [hasMore, isLoadingMore, currentLimit, scrollContainerRef]); + const columns: ColumnsType = [ { title: 'Created at', @@ -348,14 +459,16 @@ export const BackupsComponent = ({ database, isCanManageDBs }: Props) => { ); } - if (!isShowBackupConfig) { - return
; - } - return (

Backups

+ {!isBackupConfigLoading && !backupConfig?.isBackupsEnabled && ( +
+ Scheduled backups are disabled (you can enable it back in the backup configuration) +
+ )} +
@@ -380,6 +493,16 @@ export const BackupsComponent = ({ database, isCanManageDBs }: Props) => { size="small" pagination={false} /> + {isLoadingMore && ( +
+ +
+ )} + {!hasMore && backups.length > 0 && ( +
+ All backups loaded ({totalBackups} total) +
+ )}
{deleteConfimationId && ( diff --git a/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx b/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx index a8a98e7..40d54bb 100644 --- a/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx +++ b/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx @@ -74,7 +74,6 @@ export const EditBackupConfigComponent = ({ const [isShowCreateStorage, setShowCreateStorage] = useState(false); const [isShowWarn, setIsShowWarn] = useState(false); - const [isShowBackupDisableConfirm, setIsShowBackupDisableConfirm] = useState(false); const timeFormat = useMemo(() => { const is12 = getUserTimeFormat(); @@ -208,12 +207,7 @@ export const EditBackupConfigComponent = ({ { - // If disabling backups on existing database, show confirmation - if (!checked && database.id && backupConfig.isBackupsEnabled) { - setIsShowBackupDisableConfirm(true); - } else { - updateBackupConfig({ isBackupsEnabled: checked }); - } + updateBackupConfig({ isBackupsEnabled: checked }); }} size="small" /> @@ -385,41 +379,47 @@ export const EditBackupConfigComponent = ({
-
-
Storage
- { + if (storageId.includes('create-new-storage')) { + setShowCreateStorage(true); + return; + } - if (backupConfig.storage?.id) { - setIsShowWarn(true); - } - }} - size="small" - className="mr-2 max-w-[200px] grow" - options={[ - ...storages.map((s) => ({ label: s.name, value: s.id })), - { label: 'Create new storage', value: 'create-new-storage' }, - ]} - placeholder="Select storage" - /> + const selectedStorage = storages.find((s) => s.id === storageId); + updateBackupConfig({ storage: selectedStorage }); - {backupConfig.storage?.type && ( - storageIcon - )} -
+ if (backupConfig.storage?.id) { + setIsShowWarn(true); + } + }} + size="small" + className="mr-2 max-w-[200px] grow" + options={[ + ...storages.map((s) => ({ label: s.name, value: s.id })), + { label: 'Create new storage', value: 'create-new-storage' }, + ]} + placeholder="Select storage" + /> + {backupConfig.storage?.type && ( + storageIcon + )} +
+ + {backupConfig.isBackupsEnabled && ( + <>
Notifications
@@ -526,22 +526,6 @@ export const EditBackupConfigComponent = ({ hideCancelButton /> )} - - {isShowBackupDisableConfirm && ( - { - updateBackupConfig({ isBackupsEnabled: false }); - setIsShowBackupDisableConfirm(false); - }} - onDecline={() => { - setIsShowBackupDisableConfirm(false); - }} - description="All current backups will be removed? Are you sure?" - actionButtonColor="red" - actionText="Yes, disable backing up and remove all existing backup files" - cancelText="Cancel" - /> - )}
); }; diff --git a/frontend/src/features/backups/ui/ShowBackupConfigComponent.tsx b/frontend/src/features/backups/ui/ShowBackupConfigComponent.tsx index fb18571..e6db465 100644 --- a/frontend/src/features/backups/ui/ShowBackupConfigComponent.tsx +++ b/frontend/src/features/backups/ui/ShowBackupConfigComponent.tsx @@ -99,7 +99,9 @@ export const ShowBackupConfigComponent = ({ database }: Props) => {
Backups enabled
-
{backupConfig.isBackupsEnabled ? 'Yes' : 'No'}
+
+ {backupConfig.isBackupsEnabled ? 'Yes' : 'No'} +
{backupConfig.isBackupsEnabled ? ( diff --git a/frontend/src/features/databases/ui/DatabaseComponent.tsx b/frontend/src/features/databases/ui/DatabaseComponent.tsx index b5efb95..e59e5fe 100644 --- a/frontend/src/features/databases/ui/DatabaseComponent.tsx +++ b/frontend/src/features/databases/ui/DatabaseComponent.tsx @@ -1,5 +1,5 @@ import { Spin } from 'antd'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useEffect } from 'react'; import { type Database, databaseApi } from '../../../entity/databases'; @@ -27,6 +27,8 @@ export const DatabaseComponent = ({ const [database, setDatabase] = useState(); const [editDatabase, setEditDatabase] = useState(); + const scrollContainerRef = useRef(null); + const loadSettings = () => { setDatabase(undefined); setEditDatabase(undefined); @@ -42,7 +44,11 @@ export const DatabaseComponent = ({ } return ( -
+
- + )}
diff --git a/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx b/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx index fef61bf..52e836b 100644 --- a/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx +++ b/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx @@ -100,7 +100,7 @@ export const EditDatabaseSpecificDataComponent = ({ if (!editingDatabase.postgresql?.host) isAllFieldsFilled = false; if (!editingDatabase.postgresql?.port) isAllFieldsFilled = false; if (!editingDatabase.postgresql?.username) isAllFieldsFilled = false; - if (!editingDatabase.postgresql?.password) isAllFieldsFilled = false; + if (!editingDatabase.id && !editingDatabase.postgresql?.password) isAllFieldsFilled = false; if (!editingDatabase.postgresql?.database) isAllFieldsFilled = false; return ( @@ -161,6 +161,7 @@ export const EditDatabaseSpecificDataComponent = ({ host: e.target.value.trim().replace('https://', '').replace('http://', ''), }, }); + setIsConnectionTested(false); }} size="small" className="max-w-[200px] grow" @@ -199,6 +200,7 @@ export const EditDatabaseSpecificDataComponent = ({ ...editingDatabase, postgresql: { ...editingDatabase.postgresql, username: e.target.value.trim() }, }); + setIsConnectionTested(false); }} size="small" className="max-w-[200px] grow" diff --git a/frontend/src/features/databases/ui/show/ShowDatabaseSpecificDataComponent.tsx b/frontend/src/features/databases/ui/show/ShowDatabaseSpecificDataComponent.tsx index d5e083b..0a9d0e8 100644 --- a/frontend/src/features/databases/ui/show/ShowDatabaseSpecificDataComponent.tsx +++ b/frontend/src/features/databases/ui/show/ShowDatabaseSpecificDataComponent.tsx @@ -44,7 +44,7 @@ export const ShowDatabaseSpecificDataComponent = ({ database }: Props) => {
Password
-
{database.postgresql?.password ? '*********' : ''}
+
{'*************'}
diff --git a/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx b/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx index 9c5c536..1e80f2b 100644 --- a/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx +++ b/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx @@ -176,27 +176,27 @@ export function EditNotifierComponent({ if (!notifier.name) return false; if (notifier.notifierType === NotifierType.TELEGRAM && notifier.telegramNotifier) { - return validateTelegramNotifier(notifier.telegramNotifier); + return validateTelegramNotifier(!notifier.id, notifier.telegramNotifier); } if (notifier.notifierType === NotifierType.EMAIL && notifier.emailNotifier) { - return validateEmailNotifier(notifier.emailNotifier); + return validateEmailNotifier(!notifier.id, notifier.emailNotifier); } if (notifier.notifierType === NotifierType.WEBHOOK && notifier.webhookNotifier) { - return validateWebhookNotifier(notifier.webhookNotifier); + return validateWebhookNotifier(!notifier.id, notifier.webhookNotifier); } if (notifier.notifierType === NotifierType.SLACK && notifier.slackNotifier) { - return validateSlackNotifier(notifier.slackNotifier); + return validateSlackNotifier(!notifier.id, notifier.slackNotifier); } if (notifier.notifierType === NotifierType.DISCORD && notifier.discordNotifier) { - return validateDiscordNotifier(notifier.discordNotifier); + return validateDiscordNotifier(!notifier.id, notifier.discordNotifier); } if (notifier.notifierType === NotifierType.TEAMS && notifier.teamsNotifier) { - return validateTeamsNotifier(notifier.teamsNotifier); + return validateTeamsNotifier(!notifier.id, notifier.teamsNotifier); } return false; diff --git a/frontend/src/features/notifiers/ui/show/notifier/ShowEmailNotifierComponent.tsx b/frontend/src/features/notifiers/ui/show/notifier/ShowEmailNotifierComponent.tsx index f9980b3..99fb225 100644 --- a/frontend/src/features/notifiers/ui/show/notifier/ShowEmailNotifierComponent.tsx +++ b/frontend/src/features/notifiers/ui/show/notifier/ShowEmailNotifierComponent.tsx @@ -29,7 +29,7 @@ export function ShowEmailNotifierComponent({ notifier }: Props) {
SMTP password
- {notifier?.emailNotifier?.smtpPassword ? '*********' : ''} + {'*************'}
); diff --git a/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx b/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx index d9f3d9d..c90fb26 100644 --- a/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx +++ b/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx @@ -155,6 +155,10 @@ export function EditStorageComponent({ } if (storage.type === StorageType.S3) { + if (storage.id) { + return storage.s3Storage?.s3Bucket; + } + return ( storage.s3Storage?.s3Bucket && storage.s3Storage?.s3AccessKey && @@ -163,6 +167,10 @@ export function EditStorageComponent({ } if (storage.type === StorageType.GOOGLE_DRIVE) { + if (storage.id) { + return storage.googleDriveStorage?.clientId; + } + return ( storage.googleDriveStorage?.clientId && storage.googleDriveStorage?.clientSecret && @@ -171,6 +179,15 @@ export function EditStorageComponent({ } if (storage.type === StorageType.NAS) { + if (storage.id) { + return ( + storage.nasStorage?.host && + storage.nasStorage?.port && + storage.nasStorage?.share && + storage.nasStorage?.username + ); + } + return ( storage.nasStorage?.host && storage.nasStorage?.port && diff --git a/frontend/src/features/storages/ui/show/storages/ShowGoogleDriveStorageComponent.tsx b/frontend/src/features/storages/ui/show/storages/ShowGoogleDriveStorageComponent.tsx index f753df9..66617d2 100644 --- a/frontend/src/features/storages/ui/show/storages/ShowGoogleDriveStorageComponent.tsx +++ b/frontend/src/features/storages/ui/show/storages/ShowGoogleDriveStorageComponent.tsx @@ -16,16 +16,12 @@ export function ShowGoogleDriveStorageComponent({ storage }: Props) {
Client Secret
- {storage?.googleDriveStorage?.clientSecret - ? `${storage?.googleDriveStorage?.clientSecret.slice(0, 10)}***` - : '-'} + {`*************`}
User Token
- {storage?.googleDriveStorage?.tokenJson - ? `${storage?.googleDriveStorage?.tokenJson.slice(0, 10)}***` - : '-'} + {`*************`}
); diff --git a/frontend/src/features/storages/ui/show/storages/ShowNASStorageComponent.tsx b/frontend/src/features/storages/ui/show/storages/ShowNASStorageComponent.tsx index f4264dc..faae8b2 100644 --- a/frontend/src/features/storages/ui/show/storages/ShowNASStorageComponent.tsx +++ b/frontend/src/features/storages/ui/show/storages/ShowNASStorageComponent.tsx @@ -29,7 +29,7 @@ export function ShowNASStorageComponent({ storage }: Props) {
Password
- {storage?.nasStorage?.password ? '*********' : '-'} + {'*************'}
diff --git a/frontend/src/features/storages/ui/show/storages/ShowS3StorageComponent.tsx b/frontend/src/features/storages/ui/show/storages/ShowS3StorageComponent.tsx index e7b57a9..f35bba3 100644 --- a/frontend/src/features/storages/ui/show/storages/ShowS3StorageComponent.tsx +++ b/frontend/src/features/storages/ui/show/storages/ShowS3StorageComponent.tsx @@ -14,17 +14,17 @@ export function ShowS3StorageComponent({ storage }: Props) {
Region
- {storage?.s3Storage?.s3Region} + {storage?.s3Storage?.s3Region || '-'}
Access Key
- {storage?.s3Storage?.s3AccessKey ? '*********' : ''} + {'*************'}
Secret Key
- {storage?.s3Storage?.s3SecretKey ? '*********' : ''} + {'*************'}
diff --git a/frontend/src/pages/OauthStorageComponent.tsx b/frontend/src/pages/OauthStorageComponent.tsx new file mode 100644 index 0000000..9f26a52 --- /dev/null +++ b/frontend/src/pages/OauthStorageComponent.tsx @@ -0,0 +1,105 @@ +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 { EditStorageComponent } from '../features/storages/ui/edit/EditStorageComponent'; + +export function OauthStorageComponent() { + const [storage, setStorage] = useState(); + + const exchangeGoogleOauthCode = async (oauthDto: StorageOauthDto) => { + if (!oauthDto.storage.googleDriveStorage) { + alert('Google Drive storage configuration not found'); + return; + } + + const { clientId, clientSecret } = oauthDto.storage.googleDriveStorage; + const { authCode } = oauthDto; + + try { + // Exchange authorization code for access token + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + code: authCode, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: GOOGLE_DRIVE_OAUTH_REDIRECT_URL, + grant_type: 'authorization_code', + }), + }); + + if (!response.ok) { + throw new Error(`OAuth exchange failed: ${response.statusText}`); + } + + const tokenData = await response.json(); + + oauthDto.storage.googleDriveStorage.tokenJson = JSON.stringify(tokenData); + setStorage(oauthDto.storage); + } catch (error) { + alert(`Failed to exchange OAuth code: ${error}`); + } + }; + + useEffect(() => { + const oauthDtoParam = new URLSearchParams(window.location.search).get('oauthDto'); + if (!oauthDtoParam) { + alert('OAuth param not found'); + return; + } + + const decodedParam = decodeURIComponent(oauthDtoParam); + const oauthDto: StorageOauthDto = JSON.parse(decodedParam); + + if (oauthDto.storage.type === StorageType.GOOGLE_DRIVE) { + if (!oauthDto.storage.googleDriveStorage) { + alert('Google Drive storage not found'); + return; + } + + exchangeGoogleOauthCode(oauthDto); + } + }, []); + + if (!storage) { + return ( +
+ +
+ ); + } + + return ( +
+ } + open + onCancel={() => { + window.location.href = '/'; + }} + > +
+ Storage - is a place where backups will be stored (local disk, S3, etc.) +
+ + {}} + isShowName={false} + editingStorage={storage} + onChanged={() => { + window.location.href = '/'; + }} + /> +
+
+ ); +}