From c04bd54683745a25d1349f78f45cf82275916b5a Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Thu, 8 Jan 2026 15:55:52 +0300 Subject: [PATCH] FIX (download): Add streamable download of backups --- .pre-commit-config.yaml | 2 +- backend/Makefile | 4 +- backend/cmd/main.go | 5 + .../features/backups/backups/controller.go | 83 ++++- .../backups/backups/controller_test.go | 341 +++++++++++++++++- .../internal/features/backups/backups/di.go | 6 + .../backups/download_token/background.go | 32 ++ .../backups/backups/download_token/di.go | 25 ++ .../backups/backups/download_token/model.go | 21 ++ .../backups/download_token/repository.go | 60 +++ .../backups/backups/download_token/service.go | 69 ++++ .../internal/features/backups/backups/dto.go | 8 + .../features/backups/backups/service.go | 112 ++++++ .../features/backups/backups/service_test.go | 3 + .../features/backups/backups/testing.go | 8 +- .../usecases/postgresql/create_backup_uc.go | 9 +- .../backups/config/controller_test.go | 16 +- .../features/databases/controller_test.go | 40 +- .../databases/databases/mongodb/model.go | 8 +- .../internal/features/databases/service.go | 5 +- .../healthcheck/attempt/controller_test.go | 8 +- .../healthcheck/config/controller_test.go | 16 +- backend/internal/features/intervals/model.go | 6 +- .../internal/features/notifiers/controller.go | 7 +- .../features/notifiers/controller_test.go | 16 +- .../features/restores/controller_test.go | 15 +- .../internal/features/storages/controller.go | 7 +- .../features/storages/controller_test.go | 16 +- .../features/users/models/users_settings.go | 6 +- .../features/users/services/user_services.go | 15 +- .../controllers/membership_controller.go | 7 +- .../controllers/membership_controller_test.go | 12 +- .../controllers/workspace_controller_test.go | 42 ++- .../workspaces/services/membership_service.go | 18 +- ...260108111730_add_download_tokens_table.sql | 44 +++ frontend/src/entity/backups/api/backupsApi.ts | 30 +- .../features/backups/ui/BackupsComponent.tsx | 16 +- 37 files changed, 1036 insertions(+), 102 deletions(-) create mode 100644 backend/internal/features/backups/backups/download_token/background.go create mode 100644 backend/internal/features/backups/backups/download_token/di.go create mode 100644 backend/internal/features/backups/backups/download_token/model.go create mode 100644 backend/internal/features/backups/backups/download_token/repository.go create mode 100644 backend/internal/features/backups/backups/download_token/service.go create mode 100644 backend/migrations/20260108111730_add_download_tokens_table.sql diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 659269b..c57bb85 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: hooks: - id: backend-format-and-lint name: Backend Format & Lint (golangci-lint) - entry: bash -c "cd backend && golangci-lint run --fix" + entry: bash -c "cd backend && golangci-lint fmt ./internal/... ./cmd/... && golangci-lint run ./internal/... ./cmd/..." language: system files: ^backend/.*\.go$ pass_filenames: false diff --git a/backend/Makefile b/backend/Makefile index 284a13e..c83321b 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -2,10 +2,10 @@ run: go run cmd/main.go test: - go test -p=1 -count=1 -failfast -timeout 10m .\internal\... + go test -p=1 -count=1 -failfast -timeout 10m ./internal/... lint: - golangci-lint fmt && golangci-lint run + golangci-lint fmt ./internal/... ./cmd/... && golangci-lint run ./internal/... ./cmd/... migration-create: goose create $(name) sql diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 13b8d9a..1d73d8f 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -183,6 +183,7 @@ func setUpRoutes(r *gin.Engine) { userController := users_controllers.GetUserController() userController.RegisterRoutes(v1) system_healthcheck.GetHealthcheckController().RegisterRoutes(v1) + backups.GetBackupController().RegisterPublicRoutes(v1) // Setup auth middleware userService := users_services.GetUserService() @@ -243,6 +244,10 @@ func runBackgroundTasks(log *slog.Logger) { go runWithPanicLogging(log, "audit log cleanup background service", func() { audit_logs.GetAuditLogBackgroundService().Run() }) + + go runWithPanicLogging(log, "download token cleanup background service", func() { + backups.GetDownloadTokenBackgroundService().Run() + }) } func runWithPanicLogging(log *slog.Logger, serviceName string, fn func()) { diff --git a/backend/internal/features/backups/backups/controller.go b/backend/internal/features/backups/backups/controller.go index f1e1024..4b19acd 100644 --- a/backend/internal/features/backups/backups/controller.go +++ b/backend/internal/features/backups/backups/controller.go @@ -18,11 +18,17 @@ type BackupController struct { func (c *BackupController) RegisterRoutes(router *gin.RouterGroup) { router.GET("/backups", c.GetBackups) router.POST("/backups", c.MakeBackup) - router.GET("/backups/:id/file", c.GetFile) + router.POST("/backups/:id/download-token", c.GenerateDownloadToken) router.DELETE("/backups/:id", c.DeleteBackup) router.POST("/backups/:id/cancel", c.CancelBackup) } +// RegisterPublicRoutes registers routes that don't require Bearer authentication +// (they have their own authentication mechanisms like download tokens) +func (c *BackupController) RegisterPublicRoutes(router *gin.RouterGroup) { + router.GET("/backups/:id/file", c.GetFile) +} + // GetBackups // @Summary Get backups for a database // @Description Get paginated backups for the specified database @@ -159,17 +165,16 @@ func (c *BackupController) CancelBackup(ctx *gin.Context) { ctx.Status(http.StatusNoContent) } -// GetFile -// @Summary Download a backup file -// @Description Download the backup file for the specified backup +// GenerateDownloadToken +// @Summary Generate short-lived download token +// @Description Generate a token for downloading a backup file (valid for 5 minutes) // @Tags backups // @Param id path string true "Backup ID" -// @Success 200 {file} file +// @Success 200 {object} GenerateDownloadTokenResponse // @Failure 400 // @Failure 401 -// @Failure 500 -// @Router /backups/{id}/file [get] -func (c *BackupController) GetFile(ctx *gin.Context) { +// @Router /backups/{id}/download-token [post] +func (c *BackupController) GenerateDownloadToken(ctx *gin.Context) { user, ok := users_middleware.GetUserFromContext(ctx) if !ok { ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) @@ -182,7 +187,56 @@ func (c *BackupController) GetFile(ctx *gin.Context) { return } - fileReader, backup, database, err := c.backupService.GetBackupFile(user, id) + response, err := c.backupService.GenerateDownloadToken(user, id) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, response) +} + +// GetFile +// @Summary Download a backup file +// @Description Download the backup file for the specified backup using a download token +// @Tags backups +// @Param id path string true "Backup ID" +// @Param token query string true "Download token" +// @Success 200 {file} file +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /backups/{id}/file [get] +func (c *BackupController) GetFile(ctx *gin.Context) { + token := ctx.Query("token") + if token == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "download token is required"}) + return + } + + // Get backup ID from URL + backupIDParam := ctx.Param("id") + backupID, err := uuid.Parse(backupIDParam) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"}) + return + } + + downloadToken, err := c.backupService.ValidateDownloadToken(token) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired download token"}) + return + } + + // Verify token is for the requested backup + if downloadToken.BackupID != backupID { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired download token"}) + return + } + + fileReader, backup, database, err := c.backupService.GetBackupFileWithoutAuth( + downloadToken.BackupID, + ) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -195,6 +249,12 @@ func (c *BackupController) GetFile(ctx *gin.Context) { filename := c.generateBackupFilename(backup, database) + // Set Content-Length for progress tracking + if backup.BackupSizeMb > 0 { + sizeBytes := int64(backup.BackupSizeMb * 1024 * 1024) + ctx.Header("Content-Length", fmt.Sprintf("%d", sizeBytes)) + } + ctx.Header("Content-Type", "application/octet-stream") ctx.Header( "Content-Disposition", @@ -203,9 +263,12 @@ func (c *BackupController) GetFile(ctx *gin.Context) { _, err = io.Copy(ctx.Writer, fileReader) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to stream file"}) + fmt.Printf("Error streaming file: %v\n", err) return } + + // Write audit log after successful download + c.backupService.WriteAuditLogForDownload(downloadToken.UserID, backup, database) } type MakeBackupRequest struct { diff --git a/backend/internal/features/backups/backups/controller_test.go b/backend/internal/features/backups/backups/controller_test.go index 252fd41..d01ab18 100644 --- a/backend/internal/features/backups/backups/controller_test.go +++ b/backend/internal/features/backups/backups/controller_test.go @@ -18,6 +18,7 @@ import ( "databasus-backend/internal/config" audit_logs "databasus-backend/internal/features/audit_logs" + "databasus-backend/internal/features/backups/backups/download_token" backups_config "databasus-backend/internal/features/backups/config" "databasus-backend/internal/features/databases" "databasus-backend/internal/features/databases/databases/postgresql" @@ -89,7 +90,13 @@ func Test_GetBackups_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } } else { @@ -181,7 +188,13 @@ func Test_CreateBackup_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } } else { @@ -311,7 +324,13 @@ func Test_DeleteBackup_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } } else { @@ -380,7 +399,7 @@ func Test_DeleteBackup_AuditLogWritten(t *testing.T) { assert.True(t, found, "Audit log for backup deletion not found") } -func Test_DownloadBackup_PermissionsEnforced(t *testing.T) { +func Test_GenerateDownloadToken_PermissionsEnforced(t *testing.T) { tests := []struct { name string workspaceRole *users_enums.WorkspaceRole @@ -389,28 +408,28 @@ func Test_DownloadBackup_PermissionsEnforced(t *testing.T) { expectedStatusCode int }{ { - name: "workspace viewer can download backup", + name: "workspace viewer can generate token", workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleViewer; return &r }(), isGlobalAdmin: false, expectSuccess: true, expectedStatusCode: http.StatusOK, }, { - name: "workspace member can download backup", + name: "workspace member can generate token", workspaceRole: func() *users_enums.WorkspaceRole { r := users_enums.WorkspaceRoleMember; return &r }(), isGlobalAdmin: false, expectSuccess: true, expectedStatusCode: http.StatusOK, }, { - name: "non-member cannot download backup", + name: "non-member cannot generate token", workspaceRole: nil, isGlobalAdmin: false, expectSuccess: false, expectedStatusCode: http.StatusBadRequest, }, { - name: "global admin can download backup", + name: "global admin can generate token", workspaceRole: nil, isGlobalAdmin: true, expectSuccess: true, @@ -435,7 +454,13 @@ func Test_DownloadBackup_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } } else { @@ -443,21 +468,244 @@ func Test_DownloadBackup_PermissionsEnforced(t *testing.T) { testUserToken = nonMember.Token } - testResp := test_utils.MakeGetRequest( + testResp := test_utils.MakePostRequest( t, router, - fmt.Sprintf("/api/v1/backups/%s/file", backup.ID.String()), + fmt.Sprintf("/api/v1/backups/%s/download-token", backup.ID.String()), "Bearer "+testUserToken, + nil, tt.expectedStatusCode, ) - if !tt.expectSuccess { + if tt.expectSuccess { + var response GenerateDownloadTokenResponse + err := json.Unmarshal(testResp.Body, &response) + assert.NoError(t, err) + assert.NotEmpty(t, response.Token) + assert.NotEmpty(t, response.Filename) + assert.Equal(t, backup.ID, response.BackupID) + } else { assert.Contains(t, string(testResp.Body), "insufficient permissions") } }) } } +func Test_DownloadBackup_WithValidToken_Success(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + _, backup := createTestDatabaseWithBackups(workspace, owner, router) + + // Generate download token + var tokenResponse GenerateDownloadTokenResponse + test_utils.MakePostRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/download-token", backup.ID.String()), + "Bearer "+owner.Token, + nil, + http.StatusOK, + &tokenResponse, + ) + + // Download with token + testResp := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/file?token=%s", backup.ID.String(), tokenResponse.Token), + "", + http.StatusOK, + ) + + // Verify response + contentDisposition := testResp.Headers.Get("Content-Disposition") + assert.Contains(t, contentDisposition, "attachment") + assert.Contains(t, contentDisposition, tokenResponse.Filename) +} + +func Test_DownloadBackup_WithoutToken_Unauthorized(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + _, backup := createTestDatabaseWithBackups(workspace, owner, router) + + // Try to download without token + testResp := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/file", backup.ID.String()), + "", + http.StatusUnauthorized, + ) + + assert.Contains(t, string(testResp.Body), "download token is required") +} + +func Test_DownloadBackup_WithInvalidToken_Unauthorized(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + _, backup := createTestDatabaseWithBackups(workspace, owner, router) + + // Try to download with invalid token + testResp := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/file?token=%s", backup.ID.String(), "invalid-token-xyz"), + "", + http.StatusUnauthorized, + ) + + assert.Contains(t, string(testResp.Body), "invalid or expired download token") +} + +func Test_DownloadBackup_WithExpiredToken_Unauthorized(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database, backup := createTestDatabaseWithBackups(workspace, owner, router) + + // Get user for token generation + userService := users_services.GetUserService() + user, err := userService.GetUserFromToken(owner.Token) + assert.NoError(t, err) + + // Create an expired token directly in the database + expiredToken := createExpiredDownloadToken(backup.ID, user.ID) + + // Try to download with expired token + testResp := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/file?token=%s", backup.ID.String(), expiredToken), + "", + http.StatusUnauthorized, + ) + + assert.Contains(t, string(testResp.Body), "invalid or expired download token") + + // Verify audit log was NOT created for failed download + time.Sleep(100 * time.Millisecond) + auditLogService := audit_logs.GetAuditLogService() + auditLogs, err := auditLogService.GetWorkspaceAuditLogs( + workspace.ID, + &audit_logs.GetAuditLogsRequest{ + Limit: 100, + Offset: 0, + }, + ) + assert.NoError(t, err) + + found := false + for _, log := range auditLogs.AuditLogs { + if strings.Contains(log.Message, "Backup file downloaded") && + strings.Contains(log.Message, database.Name) { + found = true + break + } + } + assert.False(t, found, "Audit log should NOT be created for failed download with expired token") +} + +func Test_DownloadBackup_TokenUsedOnce_CannotReuseToken(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + _, backup := createTestDatabaseWithBackups(workspace, owner, router) + + // Generate download token + var tokenResponse GenerateDownloadTokenResponse + test_utils.MakePostRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/download-token", backup.ID.String()), + "Bearer "+owner.Token, + nil, + http.StatusOK, + &tokenResponse, + ) + + // Download with token (first time - should succeed) + test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/file?token=%s", backup.ID.String(), tokenResponse.Token), + "", + http.StatusOK, + ) + + // Try to download again with same token (should fail) + testResp := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/file?token=%s", backup.ID.String(), tokenResponse.Token), + "", + http.StatusUnauthorized, + ) + + assert.Contains(t, string(testResp.Body), "invalid or expired download token") +} + +func Test_DownloadBackup_WithDifferentBackupToken_Unauthorized(t *testing.T) { + router := createTestRouter() + owner := users_testing.CreateTestUser(users_enums.UserRoleMember) + workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router) + + database1 := createTestDatabase("Database 1", workspace.ID, owner.Token, router) + storage := createTestStorage(workspace.ID) + + configService := backups_config.GetBackupConfigService() + config1, err := configService.GetBackupConfigByDbId(database1.ID) + assert.NoError(t, err) + config1.IsBackupsEnabled = true + config1.StorageID = &storage.ID + config1.Storage = storage + _, err = configService.SaveBackupConfig(config1) + assert.NoError(t, err) + + backup1 := createTestBackup(database1, owner) + + database2 := createTestDatabase("Database 2", workspace.ID, owner.Token, router) + config2, err := configService.GetBackupConfigByDbId(database2.ID) + assert.NoError(t, err) + config2.IsBackupsEnabled = true + config2.StorageID = &storage.ID + config2.Storage = storage + _, err = configService.SaveBackupConfig(config2) + assert.NoError(t, err) + + backup2 := createTestBackup(database2, owner) + + // Generate token for backup1 + var tokenResponse GenerateDownloadTokenResponse + test_utils.MakePostRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/download-token", backup1.ID.String()), + "Bearer "+owner.Token, + nil, + http.StatusOK, + &tokenResponse, + ) + + // Try to use backup1's token to download backup2 (should fail) + testResp := test_utils.MakeGetRequest( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/file?token=%s", backup2.ID.String(), tokenResponse.Token), + "", + http.StatusUnauthorized, + ) + + assert.Contains(t, string(testResp.Body), "invalid or expired download token") +} + func Test_DownloadBackup_AuditLogWritten(t *testing.T) { router := createTestRouter() owner := users_testing.CreateTestUser(users_enums.UserRoleMember) @@ -465,11 +713,24 @@ func Test_DownloadBackup_AuditLogWritten(t *testing.T) { database, backup := createTestDatabaseWithBackups(workspace, owner, router) + // Generate download token + var tokenResponse GenerateDownloadTokenResponse + test_utils.MakePostRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/download-token", backup.ID.String()), + "Bearer "+owner.Token, + nil, + http.StatusOK, + &tokenResponse, + ) + + // Download with token test_utils.MakeGetRequest( t, router, - fmt.Sprintf("/api/v1/backups/%s/file", backup.ID.String()), - "Bearer "+owner.Token, + fmt.Sprintf("/api/v1/backups/%s/file?token=%s", backup.ID.String(), tokenResponse.Token), + "", http.StatusOK, ) @@ -544,11 +805,28 @@ func Test_DownloadBackup_ProperFilenameForPostgreSQL(t *testing.T) { backup := createTestBackup(database, owner) + // Generate download token + var tokenResponse GenerateDownloadTokenResponse + test_utils.MakePostRequestAndUnmarshal( + t, + router, + fmt.Sprintf("/api/v1/backups/%s/download-token", backup.ID.String()), + "Bearer "+owner.Token, + nil, + http.StatusOK, + &tokenResponse, + ) + + // Download with token resp := test_utils.MakeGetRequest( t, router, - fmt.Sprintf("/api/v1/backups/%s/file", backup.ID.String()), - "Bearer "+owner.Token, + fmt.Sprintf( + "/api/v1/backups/%s/file?token=%s", + backup.ID.String(), + tokenResponse.Token, + ), + "", http.StatusOK, ) @@ -817,9 +1095,38 @@ 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(context.Background(), encryption.GetFieldEncryptor(), logger, backup.ID, reader); err != nil { + if err := storages[0].SaveFile( + context.Background(), + encryption.GetFieldEncryptor(), + logger, + backup.ID, + reader, + ); err != nil { panic(fmt.Sprintf("Failed to create test backup file: %v", err)) } return backup } + +func createExpiredDownloadToken(backupID, userID uuid.UUID) string { + tokenService := GetBackupService().downloadTokenService + token, err := tokenService.Generate(backupID, userID) + if err != nil { + panic(fmt.Sprintf("Failed to generate download token: %v", err)) + } + + // Manually update the token to be expired + repo := &download_token.DownloadTokenRepository{} + downloadToken, err := repo.FindByToken(token) + if err != nil || downloadToken == nil { + panic(fmt.Sprintf("Failed to find generated token: %v", err)) + } + + // Set expiration to 10 minutes ago + downloadToken.ExpiresAt = time.Now().UTC().Add(-10 * time.Minute) + if err := repo.Update(downloadToken); err != nil { + panic(fmt.Sprintf("Failed to update token expiration: %v", err)) + } + + return token +} diff --git a/backend/internal/features/backups/backups/di.go b/backend/internal/features/backups/backups/di.go index 98a3276..690a169 100644 --- a/backend/internal/features/backups/backups/di.go +++ b/backend/internal/features/backups/backups/di.go @@ -4,6 +4,7 @@ import ( "time" audit_logs "databasus-backend/internal/features/audit_logs" + "databasus-backend/internal/features/backups/backups/download_token" "databasus-backend/internal/features/backups/backups/usecases" backups_config "databasus-backend/internal/features/backups/config" "databasus-backend/internal/features/databases" @@ -34,6 +35,7 @@ var backupService = &BackupService{ workspaces_services.GetWorkspaceService(), audit_logs.GetAuditLogService(), backupContextManager, + download_token.GetDownloadTokenService(), } var backupBackgroundService = &BackupBackgroundService{ @@ -69,3 +71,7 @@ func GetBackupController() *BackupController { func GetBackupBackgroundService() *BackupBackgroundService { return backupBackgroundService } + +func GetDownloadTokenBackgroundService() *download_token.DownloadTokenBackgroundService { + return download_token.GetDownloadTokenBackgroundService() +} diff --git a/backend/internal/features/backups/backups/download_token/background.go b/backend/internal/features/backups/backups/download_token/background.go new file mode 100644 index 0000000..c4b3390 --- /dev/null +++ b/backend/internal/features/backups/backups/download_token/background.go @@ -0,0 +1,32 @@ +package download_token + +import ( + "databasus-backend/internal/config" + "log/slog" + "time" +) + +type DownloadTokenBackgroundService struct { + downloadTokenService *DownloadTokenService + logger *slog.Logger +} + +func (s *DownloadTokenBackgroundService) Run() { + s.logger.Info("Starting download token cleanup background service") + + if config.IsShouldShutdown() { + return + } + + for { + if config.IsShouldShutdown() { + return + } + + if err := s.downloadTokenService.CleanExpiredTokens(); err != nil { + s.logger.Error("Failed to clean expired download tokens", "error", err) + } + + time.Sleep(1 * time.Minute) + } +} diff --git a/backend/internal/features/backups/backups/download_token/di.go b/backend/internal/features/backups/backups/download_token/di.go new file mode 100644 index 0000000..8ced196 --- /dev/null +++ b/backend/internal/features/backups/backups/download_token/di.go @@ -0,0 +1,25 @@ +package download_token + +import ( + "databasus-backend/internal/util/logger" +) + +var downloadTokenRepository = &DownloadTokenRepository{} + +var downloadTokenService = &DownloadTokenService{ + downloadTokenRepository, + logger.GetLogger(), +} + +var downloadTokenBackgroundService = &DownloadTokenBackgroundService{ + downloadTokenService, + logger.GetLogger(), +} + +func GetDownloadTokenService() *DownloadTokenService { + return downloadTokenService +} + +func GetDownloadTokenBackgroundService() *DownloadTokenBackgroundService { + return downloadTokenBackgroundService +} diff --git a/backend/internal/features/backups/backups/download_token/model.go b/backend/internal/features/backups/backups/download_token/model.go new file mode 100644 index 0000000..6b9ebf8 --- /dev/null +++ b/backend/internal/features/backups/backups/download_token/model.go @@ -0,0 +1,21 @@ +package download_token + +import ( + "time" + + "github.com/google/uuid" +) + +type DownloadToken struct { + ID uuid.UUID `json:"id" gorm:"column:id;primaryKey"` + Token string `json:"token" gorm:"column:token;uniqueIndex;not null"` + BackupID uuid.UUID `json:"backupId" gorm:"column:backup_id;not null"` + UserID uuid.UUID `json:"userId" gorm:"column:user_id;not null"` + ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at;not null"` + Used bool `json:"used" gorm:"column:used;not null;default:false"` + CreatedAt time.Time `json:"createdAt" gorm:"column:created_at;not null"` +} + +func (DownloadToken) TableName() string { + return "download_tokens" +} diff --git a/backend/internal/features/backups/backups/download_token/repository.go b/backend/internal/features/backups/backups/download_token/repository.go new file mode 100644 index 0000000..79491bd --- /dev/null +++ b/backend/internal/features/backups/backups/download_token/repository.go @@ -0,0 +1,60 @@ +package download_token + +import ( + "crypto/rand" + "databasus-backend/internal/storage" + "encoding/base64" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type DownloadTokenRepository struct{} + +func (r *DownloadTokenRepository) Create(token *DownloadToken) error { + if token.ID == uuid.Nil { + token.ID = uuid.New() + } + if token.CreatedAt.IsZero() { + token.CreatedAt = time.Now().UTC() + } + return storage.GetDb().Create(token).Error +} + +func (r *DownloadTokenRepository) FindByToken(token string) (*DownloadToken, error) { + var downloadToken DownloadToken + + err := storage.GetDb(). + Where("token = ?", token). + First(&downloadToken).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + + return &downloadToken, nil +} + +func (r *DownloadTokenRepository) Update(token *DownloadToken) error { + return storage.GetDb().Save(token).Error +} + +func (r *DownloadTokenRepository) DeleteExpired(before time.Time) error { + return storage.GetDb(). + Where("expires_at < ?", before). + Delete(&DownloadToken{}).Error +} + +func GenerateSecureToken() string { + b := make([]byte, 32) + + if _, err := rand.Read(b); err != nil { + panic("failed to generate secure random token: " + err.Error()) + } + + return base64.URLEncoding.EncodeToString(b) +} diff --git a/backend/internal/features/backups/backups/download_token/service.go b/backend/internal/features/backups/backups/download_token/service.go new file mode 100644 index 0000000..2822a45 --- /dev/null +++ b/backend/internal/features/backups/backups/download_token/service.go @@ -0,0 +1,69 @@ +package download_token + +import ( + "errors" + "log/slog" + "time" + + "github.com/google/uuid" +) + +type DownloadTokenService struct { + repository *DownloadTokenRepository + logger *slog.Logger +} + +func (s *DownloadTokenService) Generate(backupID, userID uuid.UUID) (string, error) { + token := GenerateSecureToken() + + downloadToken := &DownloadToken{ + Token: token, + BackupID: backupID, + UserID: userID, + ExpiresAt: time.Now().UTC().Add(5 * time.Minute), + Used: false, + } + + if err := s.repository.Create(downloadToken); err != nil { + return "", err + } + + s.logger.Info("Generated download token", "backupId", backupID, "userId", userID) + return token, nil +} + +func (s *DownloadTokenService) ValidateAndConsume(token string) (*DownloadToken, error) { + dt, err := s.repository.FindByToken(token) + if err != nil { + return nil, err + } + + if dt == nil { + return nil, errors.New("invalid token") + } + + if dt.Used { + return nil, errors.New("token already used") + } + + if time.Now().UTC().After(dt.ExpiresAt) { + return nil, errors.New("token expired") + } + + dt.Used = true + if err := s.repository.Update(dt); err != nil { + s.logger.Error("Failed to mark token as used", "error", err) + } + + s.logger.Info("Token validated and consumed", "backupId", dt.BackupID) + return dt, nil +} + +func (s *DownloadTokenService) CleanExpiredTokens() error { + now := time.Now().UTC() + if err := s.repository.DeleteExpired(now); err != nil { + return err + } + s.logger.Debug("Cleaned expired download tokens") + return nil +} diff --git a/backend/internal/features/backups/backups/dto.go b/backend/internal/features/backups/backups/dto.go index 6f57164..29c9358 100644 --- a/backend/internal/features/backups/backups/dto.go +++ b/backend/internal/features/backups/backups/dto.go @@ -3,6 +3,8 @@ package backups import ( "databasus-backend/internal/features/backups/backups/encryption" "io" + + "github.com/google/uuid" ) type GetBackupsRequest struct { @@ -18,6 +20,12 @@ type GetBackupsResponse struct { Offset int `json:"offset"` } +type GenerateDownloadTokenResponse struct { + Token string `json:"token"` + Filename string `json:"filename"` + BackupID uuid.UUID `json:"backupId"` +} + type decryptionReaderCloser struct { *encryption.DecryptionReader baseReader io.ReadCloser diff --git a/backend/internal/features/backups/backups/service.go b/backend/internal/features/backups/backups/service.go index 86c9947..ea19e3f 100644 --- a/backend/internal/features/backups/backups/service.go +++ b/backend/internal/features/backups/backups/service.go @@ -12,6 +12,7 @@ import ( "time" audit_logs "databasus-backend/internal/features/audit_logs" + "databasus-backend/internal/features/backups/backups/download_token" "databasus-backend/internal/features/backups/backups/encryption" backups_config "databasus-backend/internal/features/backups/config" "databasus-backend/internal/features/databases" @@ -44,6 +45,7 @@ type BackupService struct { workspaceService *workspaces_services.WorkspaceService auditLogService *audit_logs.AuditLogService backupContextManager *BackupContextManager + downloadTokenService *download_token.DownloadTokenService } func (s *BackupService) AddBackupRemoveListener(listener BackupRemoveListener) { @@ -683,3 +685,113 @@ func (s *BackupService) getBackupReader(backupID uuid.UUID) (io.ReadCloser, erro fileReader, }, nil } + +func (s *BackupService) GenerateDownloadToken( + user *users_models.User, + backupID uuid.UUID, +) (*GenerateDownloadTokenResponse, error) { + backup, err := s.backupRepository.FindByID(backupID) + if err != nil { + return nil, err + } + + database, err := s.databaseService.GetDatabaseByID(backup.DatabaseID) + if err != nil { + return nil, err + } + + if database.WorkspaceID == nil { + return nil, errors.New("cannot download backup for database without workspace") + } + + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user) + if err != nil { + return nil, err + } + if !canAccess { + return nil, errors.New("insufficient permissions to download backup for this database") + } + + token, err := s.downloadTokenService.Generate(backupID, user.ID) + if err != nil { + return nil, err + } + + filename := s.generateBackupFilename(backup, database) + + s.auditLogService.WriteAuditLog( + fmt.Sprintf("Download token generated for backup of database: %s", database.Name), + &user.ID, + database.WorkspaceID, + ) + + return &GenerateDownloadTokenResponse{ + Token: token, + Filename: filename, + BackupID: backupID, + }, nil +} + +func (s *BackupService) ValidateDownloadToken(token string) (*download_token.DownloadToken, error) { + return s.downloadTokenService.ValidateAndConsume(token) +} + +func (s *BackupService) GetBackupFileWithoutAuth( + backupID uuid.UUID, +) (io.ReadCloser, *Backup, *databases.Database, error) { + backup, err := s.backupRepository.FindByID(backupID) + if err != nil { + return nil, nil, nil, err + } + + database, err := s.databaseService.GetDatabaseByID(backup.DatabaseID) + if err != nil { + return nil, nil, nil, err + } + + reader, err := s.getBackupReader(backupID) + if err != nil { + return nil, nil, nil, err + } + + return reader, backup, database, nil +} + +func (s *BackupService) WriteAuditLogForDownload( + userID uuid.UUID, + backup *Backup, + database *databases.Database, +) { + s.auditLogService.WriteAuditLog( + fmt.Sprintf( + "Backup file downloaded for database: %s (ID: %s)", + database.Name, + backup.ID.String(), + ), + &userID, + database.WorkspaceID, + ) +} + +func (s *BackupService) generateBackupFilename( + backup *Backup, + database *databases.Database, +) string { + timestamp := backup.CreatedAt.Format("2006-01-02_15-04-05") + safeName := sanitizeFilename(database.Name) + extension := s.getBackupExtension(database.Type) + return fmt.Sprintf("%s_backup_%s%s", safeName, timestamp, extension) +} + +func (s *BackupService) getBackupExtension(dbType databases.DatabaseType) string { + switch dbType { + case databases.DatabaseTypeMysql, databases.DatabaseTypeMariadb: + return ".sql.zst" + case databases.DatabaseTypePostgres: + return ".dump" + case databases.DatabaseTypeMongodb: + return ".archive" + default: + return ".backup" + } +} diff --git a/backend/internal/features/backups/backups/service_test.go b/backend/internal/features/backups/backups/service_test.go index 6e7081b..a171fdf 100644 --- a/backend/internal/features/backups/backups/service_test.go +++ b/backend/internal/features/backups/backups/service_test.go @@ -65,6 +65,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) { workspaces_services.GetWorkspaceService(), nil, NewBackupContextManager(), + nil, } // Set up expectations @@ -113,6 +114,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) { workspaces_services.GetWorkspaceService(), nil, NewBackupContextManager(), + nil, } backupService.MakeBackup(database.ID, true) @@ -138,6 +140,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) { workspaces_services.GetWorkspaceService(), nil, NewBackupContextManager(), + nil, } // capture arguments diff --git a/backend/internal/features/backups/backups/testing.go b/backend/internal/features/backups/backups/testing.go index dde177a..a4af5b4 100644 --- a/backend/internal/features/backups/backups/testing.go +++ b/backend/internal/features/backups/backups/testing.go @@ -14,13 +14,19 @@ import ( ) func CreateTestRouter() *gin.Engine { - return workspaces_testing.CreateTestRouter( + router := workspaces_testing.CreateTestRouter( workspaces_controllers.GetWorkspaceController(), workspaces_controllers.GetMembershipController(), databases.GetDatabaseController(), backups_config.GetBackupConfigController(), GetBackupController(), ) + + // Register public routes (no auth required - token-based) + v1 := router.Group("/api/v1") + GetBackupController().RegisterPublicRoutes(v1) + + return router } // WaitForBackupCompletion waits for a new backup to be created and completed (or failed) diff --git a/backend/internal/features/backups/backups/usecases/postgresql/create_backup_uc.go b/backend/internal/features/backups/backups/usecases/postgresql/create_backup_uc.go index d24eca5..22ce2dd 100644 --- a/backend/internal/features/backups/backups/usecases/postgresql/create_backup_uc.go +++ b/backend/internal/features/backups/backups/usecases/postgresql/create_backup_uc.go @@ -135,7 +135,14 @@ func (uc *CreatePostgresqlBackupUsecase) streamToStorage( cmd := exec.CommandContext(ctx, pgBin, args...) uc.logger.Info("Executing PostgreSQL backup command", "command", cmd.String()) - if err := uc.setupPgEnvironment(cmd, pgpassFile, db.Postgresql.IsHttps, password, db.Postgresql.CpuCount, pgBin); err != nil { + if err := uc.setupPgEnvironment( + cmd, + pgpassFile, + db.Postgresql.IsHttps, + password, + db.Postgresql.CpuCount, + pgBin, + ); err != nil { return nil, err } diff --git a/backend/internal/features/backups/config/controller_test.go b/backend/internal/features/backups/config/controller_test.go index 841fb28..379717b 100644 --- a/backend/internal/features/backups/config/controller_test.go +++ b/backend/internal/features/backups/config/controller_test.go @@ -97,7 +97,13 @@ func Test_SaveBackupConfig_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } @@ -244,7 +250,13 @@ func Test_GetBackupConfigByDbID_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } else { nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) diff --git a/backend/internal/features/databases/controller_test.go b/backend/internal/features/databases/controller_test.go index c6359e1..8c632ba 100644 --- a/backend/internal/features/databases/controller_test.go +++ b/backend/internal/features/databases/controller_test.go @@ -151,7 +151,13 @@ func Test_CreateDatabase_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } @@ -263,7 +269,13 @@ func Test_UpdateDatabase_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } @@ -365,7 +377,13 @@ func Test_DeleteDatabase_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } @@ -430,7 +448,13 @@ func Test_GetDatabase_PermissionsEnforced(t *testing.T) { testUser = admin.Token } else if tt.userRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.userRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.userRole, + owner.Token, + router, + ) testUser = member.Token } else { nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) @@ -654,7 +678,13 @@ func Test_CopyDatabase_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } diff --git a/backend/internal/features/databases/databases/mongodb/model.go b/backend/internal/features/databases/databases/mongodb/model.go index c4f2244..3c925e1 100644 --- a/backend/internal/features/databases/databases/mongodb/model.go +++ b/backend/internal/features/databases/databases/mongodb/model.go @@ -97,7 +97,13 @@ func (m *MongodbDatabase) TestConnection( } m.Version = detectedVersion - if err := checkBackupPermissions(ctx, client, m.Username, m.Database, m.AuthDatabase); err != nil { + if err := checkBackupPermissions( + ctx, + client, + m.Username, + m.Database, + m.AuthDatabase, + ); err != nil { return err } diff --git a/backend/internal/features/databases/service.go b/backend/internal/features/databases/service.go index 323c79c..a42a5d6 100644 --- a/backend/internal/features/databases/service.go +++ b/backend/internal/features/databases/service.go @@ -631,7 +631,10 @@ func (s *DatabaseService) IsUserReadOnly( usingDatabase = existingDatabase } else { if database.WorkspaceID != nil { - canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user) + canAccess, _, err := s.workspaceService.CanUserAccessWorkspace( + *database.WorkspaceID, + user, + ) if err != nil { return false, nil, err } diff --git a/backend/internal/features/healthcheck/attempt/controller_test.go b/backend/internal/features/healthcheck/attempt/controller_test.go index ee78988..e0aaea4 100644 --- a/backend/internal/features/healthcheck/attempt/controller_test.go +++ b/backend/internal/features/healthcheck/attempt/controller_test.go @@ -109,7 +109,13 @@ func Test_GetAttemptsByDatabase_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } else { nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) diff --git a/backend/internal/features/healthcheck/config/controller_test.go b/backend/internal/features/healthcheck/config/controller_test.go index 74593de..1b50e69 100644 --- a/backend/internal/features/healthcheck/config/controller_test.go +++ b/backend/internal/features/healthcheck/config/controller_test.go @@ -88,7 +88,13 @@ func Test_SaveHealthcheckConfig_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } @@ -226,7 +232,13 @@ func Test_GetHealthcheckConfig_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } else { nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) diff --git a/backend/internal/features/intervals/model.go b/backend/internal/features/intervals/model.go index 921aa96..2f248e2 100644 --- a/backend/internal/features/intervals/model.go +++ b/backend/internal/features/intervals/model.go @@ -13,11 +13,11 @@ type Interval struct { ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` Interval IntervalType `json:"interval" gorm:"type:text;not null"` - TimeOfDay *string `json:"timeOfDay" gorm:"type:text;"` + TimeOfDay *string `json:"timeOfDay" gorm:"type:text;"` // only for WEEKLY - Weekday *int `json:"weekday,omitempty" gorm:"type:int"` + Weekday *int `json:"weekday,omitempty" gorm:"type:int"` // only for MONTHLY - DayOfMonth *int `json:"dayOfMonth,omitempty" gorm:"type:int"` + DayOfMonth *int `json:"dayOfMonth,omitempty" gorm:"type:int"` // only for CRON CronExpression *string `json:"cronExpression,omitempty" gorm:"type:text"` } diff --git a/backend/internal/features/notifiers/controller.go b/backend/internal/features/notifiers/controller.go index aa28d13..27b78dd 100644 --- a/backend/internal/features/notifiers/controller.go +++ b/backend/internal/features/notifiers/controller.go @@ -263,7 +263,12 @@ func (c *NotifierController) TransferNotifierToWorkspace(ctx *gin.Context) { return } - if err := c.notifierService.TransferNotifierToWorkspace(user, id, request.TargetWorkspaceID, nil); err != nil { + if err := c.notifierService.TransferNotifierToWorkspace( + user, + id, + request.TargetWorkspaceID, + nil, + ); err != nil { if errors.Is(err, ErrInsufficientPermissionsInSourceWorkspace) || errors.Is(err, ErrInsufficientPermissionsInTargetWorkspace) { 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 bb577fd..6ef7b8a 100644 --- a/backend/internal/features/notifiers/controller_test.go +++ b/backend/internal/features/notifiers/controller_test.go @@ -1050,8 +1050,20 @@ func Test_TransferNotifier_PermissionsEnforced(t *testing.T) { testUserToken = admin.Token } else if tt.sourceRole != nil { testUser := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(sourceWorkspace, testUser, *tt.sourceRole, sourceOwner.Token, router) - workspaces_testing.AddMemberToWorkspace(targetWorkspace, testUser, *tt.targetRole, targetOwner.Token, router) + workspaces_testing.AddMemberToWorkspace( + sourceWorkspace, + testUser, + *tt.sourceRole, + sourceOwner.Token, + router, + ) + workspaces_testing.AddMemberToWorkspace( + targetWorkspace, + testUser, + *tt.targetRole, + targetOwner.Token, + router, + ) testUserToken = testUser.Token } diff --git a/backend/internal/features/restores/controller_test.go b/backend/internal/features/restores/controller_test.go index 5df0743..2381bbc 100644 --- a/backend/internal/features/restores/controller_test.go +++ b/backend/internal/features/restores/controller_test.go @@ -290,7 +290,12 @@ func Test_RestoreBackup_DiskSpaceValidation(t *testing.T) { }, } } else { - mysqlDB := createTestMySQLDatabase("Test MySQL DB", workspace.ID, owner.Token, router) + mysqlDB := createTestMySQLDatabase( + "Test MySQL DB", + workspace.ID, + owner.Token, + router, + ) storage := createTestStorage(workspace.ID) configService := backups_config.GetBackupConfigService() @@ -530,7 +535,13 @@ 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(context.Background(), fieldEncryptor, logger, backup.ID, reader); err != nil { + if err := storages[0].SaveFile( + context.Background(), + fieldEncryptor, + logger, + backup.ID, + reader, + ); err != nil { panic(fmt.Sprintf("Failed to create test backup file: %v", err)) } diff --git a/backend/internal/features/storages/controller.go b/backend/internal/features/storages/controller.go index 75eeaa4..2c1bd73 100644 --- a/backend/internal/features/storages/controller.go +++ b/backend/internal/features/storages/controller.go @@ -263,7 +263,12 @@ func (c *StorageController) TransferStorageToWorkspace(ctx *gin.Context) { return } - if err := c.storageService.TransferStorageToWorkspace(user, id, request.TargetWorkspaceID, nil); err != nil { + if err := c.storageService.TransferStorageToWorkspace( + user, + id, + request.TargetWorkspaceID, + nil, + ); err != nil { if errors.Is(err, ErrInsufficientPermissionsInSourceWorkspace) || errors.Is(err, ErrInsufficientPermissionsInTargetWorkspace) { ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) diff --git a/backend/internal/features/storages/controller_test.go b/backend/internal/features/storages/controller_test.go index cfd237c..2208841 100644 --- a/backend/internal/features/storages/controller_test.go +++ b/backend/internal/features/storages/controller_test.go @@ -1071,8 +1071,20 @@ func Test_TransferStorage_PermissionsEnforced(t *testing.T) { testUserToken = admin.Token } else if tt.sourceRole != nil { testUser := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(sourceWorkspace, testUser, *tt.sourceRole, sourceOwner.Token, router) - workspaces_testing.AddMemberToWorkspace(targetWorkspace, testUser, *tt.targetRole, targetOwner.Token, router) + workspaces_testing.AddMemberToWorkspace( + sourceWorkspace, + testUser, + *tt.sourceRole, + sourceOwner.Token, + router, + ) + workspaces_testing.AddMemberToWorkspace( + targetWorkspace, + testUser, + *tt.targetRole, + targetOwner.Token, + router, + ) testUserToken = testUser.Token } diff --git a/backend/internal/features/users/models/users_settings.go b/backend/internal/features/users/models/users_settings.go index 5e75d8a..23c882a 100644 --- a/backend/internal/features/users/models/users_settings.go +++ b/backend/internal/features/users/models/users_settings.go @@ -3,11 +3,11 @@ package users_models import "github.com/google/uuid" type UsersSettings struct { - ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;default:gen_random_uuid()"` // means that any user can register via sign up form without invitation - IsAllowExternalRegistrations bool `json:"isAllowExternalRegistrations" gorm:"column:is_allow_external_registrations"` + IsAllowExternalRegistrations bool `json:"isAllowExternalRegistrations" gorm:"column:is_allow_external_registrations"` // means that any user with role MEMBER can invite other users - IsAllowMemberInvitations bool `json:"isAllowMemberInvitations" gorm:"column:is_allow_member_invitations"` + IsAllowMemberInvitations bool `json:"isAllowMemberInvitations" gorm:"column:is_allow_member_invitations"` // means that any user with role MEMBER can create their own workspaces IsMemberAllowedToCreateWorkspaces bool `json:"isMemberAllowedToCreateWorkspaces" gorm:"column:is_member_allowed_to_create_workspaces"` } diff --git a/backend/internal/features/users/services/user_services.go b/backend/internal/features/users/services/user_services.go index 6a431dc..530314a 100644 --- a/backend/internal/features/users/services/user_services.go +++ b/backend/internal/features/users/services/user_services.go @@ -56,11 +56,17 @@ func (s *UserService) SignUp(request *users_dto.SignUpRequestDTO) error { // If user exists with INVITED status, activate them and set password if existingUser != nil && existingUser.Status == users_enums.UserStatusInvited { - if err := s.userRepository.UpdateUserPassword(existingUser.ID, hashedPasswordStr); err != nil { + if err := s.userRepository.UpdateUserPassword( + existingUser.ID, + hashedPasswordStr, + ); err != nil { return fmt.Errorf("failed to set password: %w", err) } - if err := s.userRepository.UpdateUserStatus(existingUser.ID, users_enums.UserStatusActive); err != nil { + if err := s.userRepository.UpdateUserStatus( + existingUser.ID, + users_enums.UserStatusActive, + ); err != nil { return fmt.Errorf("failed to activate user: %w", err) } @@ -635,7 +641,10 @@ func (s *UserService) getOrCreateUserFromOAuth( if userByEmail != nil { if userByEmail.Status == users_enums.UserStatusInvited { - if err := s.userRepository.UpdateUserStatus(userByEmail.ID, users_enums.UserStatusActive); err != nil { + if err := s.userRepository.UpdateUserStatus( + userByEmail.ID, + users_enums.UserStatusActive, + ); err != nil { return nil, fmt.Errorf("failed to activate user: %w", err) } diff --git a/backend/internal/features/workspaces/controllers/membership_controller.go b/backend/internal/features/workspaces/controllers/membership_controller.go index ab78bb2..8297270 100644 --- a/backend/internal/features/workspaces/controllers/membership_controller.go +++ b/backend/internal/features/workspaces/controllers/membership_controller.go @@ -161,7 +161,12 @@ func (c *MembershipController) ChangeMemberRole(ctx *gin.Context) { return } - if err := c.membershipService.ChangeMemberRole(workspaceID, userID, &request, user); err != nil { + if err := c.membershipService.ChangeMemberRole( + workspaceID, + userID, + &request, + user, + ); err != nil { if errors.Is(err, workspaces_errors.ErrInsufficientPermissionsToManageMembers) || errors.Is(err, workspaces_errors.ErrOnlyOwnerCanAddManageAdmins) { ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) diff --git a/backend/internal/features/workspaces/controllers/membership_controller_test.go b/backend/internal/features/workspaces/controllers/membership_controller_test.go index 5d406cb..b117ade 100644 --- a/backend/internal/features/workspaces/controllers/membership_controller_test.go +++ b/backend/internal/features/workspaces/controllers/membership_controller_test.go @@ -123,7 +123,11 @@ func Test_GetWorkspaceMembers_PermissionsEnforced(t *testing.T) { "Bearer "+testUserToken, tt.expectedStatusCode, ) - assert.Contains(t, string(resp.Body), "insufficient permissions to view workspace members") + assert.Contains( + t, + string(resp.Body), + "insufficient permissions to view workspace members", + ) } }) } @@ -1202,7 +1206,11 @@ func Test_TransferWorkspaceOwnership_PermissionsEnforced(t *testing.T) { if tt.expectSuccess { assert.Contains(t, string(resp.Body), "Ownership transferred successfully") } else { - assert.Contains(t, string(resp.Body), "only workspace owner or admin can transfer ownership") + assert.Contains( + t, + string(resp.Body), + "only workspace owner or admin can transfer ownership", + ) } }) } diff --git a/backend/internal/features/workspaces/controllers/workspace_controller_test.go b/backend/internal/features/workspaces/controllers/workspace_controller_test.go index f9305bc..c381f27 100644 --- a/backend/internal/features/workspaces/controllers/workspace_controller_test.go +++ b/backend/internal/features/workspaces/controllers/workspace_controller_test.go @@ -100,7 +100,11 @@ func Test_CreateWorkspace_PermissionsEnforced(t *testing.T) { request, tt.expectedStatusCode, ) - assert.Contains(t, string(resp.Body), "insufficient permissions to create workspaces") + assert.Contains( + t, + string(resp.Body), + "insufficient permissions to create workspaces", + ) } }) } @@ -263,7 +267,13 @@ func Test_GetSingleWorkspace_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } else { nonMember := users_testing.CreateTestUser(users_enums.UserRoleMember) @@ -365,7 +375,13 @@ func Test_UpdateWorkspace_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } @@ -396,7 +412,11 @@ func Test_UpdateWorkspace_PermissionsEnforced(t *testing.T) { updateRequest, tt.expectedStatusCode, ) - assert.Contains(t, string(resp.Body), "insufficient permissions to update workspace") + assert.Contains( + t, + string(resp.Body), + "insufficient permissions to update workspace", + ) } }) } @@ -461,7 +481,13 @@ func Test_DeleteWorkspace_PermissionsEnforced(t *testing.T) { testUserToken = owner.Token } else if tt.workspaceRole != nil { member := users_testing.CreateTestUser(users_enums.UserRoleMember) - workspaces_testing.AddMemberToWorkspace(workspace, member, *tt.workspaceRole, owner.Token, router) + workspaces_testing.AddMemberToWorkspace( + workspace, + member, + *tt.workspaceRole, + owner.Token, + router, + ) testUserToken = member.Token } @@ -475,7 +501,11 @@ func Test_DeleteWorkspace_PermissionsEnforced(t *testing.T) { if tt.expectSuccess { assert.Contains(t, string(resp.Body), "Workspace deleted successfully") } else { - assert.Contains(t, string(resp.Body), "only workspace owner or admin can delete workspace") + assert.Contains( + t, + string(resp.Body), + "only workspace owner or admin can delete workspace", + ) } }) } diff --git a/backend/internal/features/workspaces/services/membership_service.go b/backend/internal/features/workspaces/services/membership_service.go index fd2c528..0c3e3f8 100644 --- a/backend/internal/features/workspaces/services/membership_service.go +++ b/backend/internal/features/workspaces/services/membership_service.go @@ -173,7 +173,11 @@ func (s *MembershipService) ChangeMemberRole( return workspaces_errors.ErrUserNotFound } - if err := s.membershipRepository.UpdateMemberRole(memberUserID, workspaceID, request.Role); err != nil { + if err := s.membershipRepository.UpdateMemberRole( + memberUserID, + workspaceID, + request.Role, + ); err != nil { return fmt.Errorf("failed to update member role: %w", err) } @@ -283,11 +287,19 @@ func (s *MembershipService) TransferOwnership( return workspaces_errors.ErrNoCurrentWorkspaceOwner } - if err := s.membershipRepository.UpdateMemberRole(newOwner.ID, workspaceID, users_enums.WorkspaceRoleOwner); err != nil { + if err := s.membershipRepository.UpdateMemberRole( + newOwner.ID, + workspaceID, + users_enums.WorkspaceRoleOwner, + ); err != nil { return fmt.Errorf("failed to update new owner role: %w", err) } - if err := s.membershipRepository.UpdateMemberRole(currentOwner.UserID, workspaceID, users_enums.WorkspaceRoleAdmin); err != nil { + if err := s.membershipRepository.UpdateMemberRole( + currentOwner.UserID, + workspaceID, + users_enums.WorkspaceRoleAdmin, + ); err != nil { return fmt.Errorf("failed to update previous owner role: %w", err) } diff --git a/backend/migrations/20260108111730_add_download_tokens_table.sql b/backend/migrations/20260108111730_add_download_tokens_table.sql new file mode 100644 index 0000000..7c55633 --- /dev/null +++ b/backend/migrations/20260108111730_add_download_tokens_table.sql @@ -0,0 +1,44 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE download_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token TEXT NOT NULL UNIQUE, + backup_id UUID NOT NULL, + user_id UUID NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE download_tokens + ADD CONSTRAINT fk_download_tokens_backup_id + FOREIGN KEY (backup_id) + REFERENCES backups (id) + ON DELETE CASCADE; + +ALTER TABLE download_tokens + ADD CONSTRAINT fk_download_tokens_user_id + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE; + +CREATE INDEX idx_download_tokens_token ON download_tokens (token); +CREATE INDEX idx_download_tokens_expires_at ON download_tokens (expires_at); +CREATE INDEX idx_download_tokens_backup_id ON download_tokens (backup_id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP INDEX IF EXISTS idx_download_tokens_backup_id; +DROP INDEX IF EXISTS idx_download_tokens_expires_at; +DROP INDEX IF EXISTS idx_download_tokens_token; + +ALTER TABLE download_tokens DROP CONSTRAINT IF EXISTS fk_download_tokens_user_id; +ALTER TABLE download_tokens DROP CONSTRAINT IF EXISTS fk_download_tokens_backup_id; + +DROP TABLE IF EXISTS download_tokens; + +-- +goose StatementEnd diff --git a/frontend/src/entity/backups/api/backupsApi.ts b/frontend/src/entity/backups/api/backupsApi.ts index 0bf7c38..2cc75d0 100644 --- a/frontend/src/entity/backups/api/backupsApi.ts +++ b/frontend/src/entity/backups/api/backupsApi.ts @@ -29,23 +29,25 @@ export const backupsApi = { return apiHelper.fetchDeleteRaw(`${getApplicationServer()}/api/v1/backups/${id}`); }, - async downloadBackup(id: string): Promise<{ blob: Blob; filename: string }> { - const result = await apiHelper.fetchGetBlobWithHeaders( - `${getApplicationServer()}/api/v1/backups/${id}/file`, - ); + async downloadBackup(id: string): Promise { + // Generate short-lived download token + const tokenResponse = await apiHelper.fetchPostJson<{ + token: string; + filename: string; + backupId: string; + }>(`${getApplicationServer()}/api/v1/backups/${id}/download-token`, new RequestOptions()); - // Extract filename from Content-Disposition header - const contentDisposition = result.headers.get('Content-Disposition'); - let filename = `backup_${id}.backup`; // fallback filename + // Create direct download link with token + const downloadUrl = `${getApplicationServer()}/api/v1/backups/${id}/file?token=${tokenResponse.token}`; - if (contentDisposition) { - const filenameMatch = contentDisposition.match(/filename="?(.+?)"?$/); - if (filenameMatch && filenameMatch[1]) { - filename = filenameMatch[1]; - } - } + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = tokenResponse.filename; + link.style.display = 'none'; - return { blob: result.blob, filename }; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); }, async cancelBackup(id: string) { diff --git a/frontend/src/features/backups/ui/BackupsComponent.tsx b/frontend/src/features/backups/ui/BackupsComponent.tsx index f996a46..318dcd9 100644 --- a/frontend/src/features/backups/ui/BackupsComponent.tsx +++ b/frontend/src/features/backups/ui/BackupsComponent.tsx @@ -64,21 +64,7 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef const downloadBackup = async (backupId: string) => { try { - const { blob, filename } = await backupsApi.downloadBackup(backupId); - - // Create a download link - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - - // Trigger download - document.body.appendChild(link); - link.click(); - - // Cleanup - document.body.removeChild(link); - window.URL.revokeObjectURL(url); + await backupsApi.downloadBackup(backupId); } catch (e) { alert((e as Error).message); } finally {